From 684a45d386ce7b5a4f7205e580dd454ddb042fa0 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 11 Aug 2023 12:52:51 +0800 Subject: [PATCH 01/66] Add documentation and test coverage for app and document. --- cocoa/src/toga_cocoa/app.py | 19 +- cocoa/src/toga_cocoa/documents.py | 6 +- core/src/toga/__init__.py | 3 +- core/src/toga/app.py | 337 +++++----- core/src/toga/documents.py | 107 +++- core/tests/app/__init__.py | 0 core/tests/app/test_app.py | 647 ++++++++++++++++++++ core/tests/app/test_documentapp.py | 158 +++++ core/tests/app/test_mainwindow.py | 46 ++ core/tests/app/test_windowset.py | 118 ++++ core/tests/test_app.py | 224 ------- core/tests/test_deprecated_factory.py | 23 - core/tests/test_documents.py | 65 +- docs/reference/api/app.rst | 81 ++- docs/reference/api/documentapp.rst | 90 +++ docs/reference/api/index.rst | 18 +- docs/reference/data/widgets_by_platform.csv | 3 +- docs/spelling_wordlist | 7 +- dummy/src/toga_dummy/app.py | 48 +- dummy/src/toga_dummy/documents.py | 1 + gtk/src/toga_gtk/app.py | 28 +- gtk/src/toga_gtk/documents.py | 3 + 22 files changed, 1546 insertions(+), 486 deletions(-) create mode 100644 core/tests/app/__init__.py create mode 100644 core/tests/app/test_app.py create mode 100644 core/tests/app/test_documentapp.py create mode 100644 core/tests/app/test_mainwindow.py create mode 100644 core/tests/app/test_windowset.py delete mode 100644 core/tests/test_app.py create mode 100644 docs/reference/api/documentapp.rst diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 54bbd2a2e3..b946f3ddf8 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -2,6 +2,7 @@ import inspect import os import sys +from pathlib import Path from urllib.parse import unquote, urlparse from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy @@ -119,6 +120,9 @@ 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) @@ -379,9 +383,6 @@ def _submenu(self, group, menubar): return submenu def main_loop(self): - # Stimulate the build of the app - self.create() - self.loop.run_forever(lifecycle=CocoaLifecycle(self.native)) def set_main_window(self, window): @@ -511,13 +512,7 @@ def open_document(self, fileURL): """ # Convert a cocoa fileURL to a file path. fileURL = fileURL.rstrip("/") - path = unquote(urlparse(fileURL).path) - extension = os.path.splitext(path)[1][1:] - - # Create the document instance - DocType = self.interface.document_types[extension] - document = DocType(path, app=self.interface) - self.interface._documents.append(document) + path = Path(unquote(urlparse(fileURL).path)) - # Show the document. - document.show() + # 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 81166fb51c..03b546afd7 100644 --- a/cocoa/src/toga_cocoa/documents.py +++ b/cocoa/src/toga_cocoa/documents.py @@ -1,3 +1,4 @@ +import os from urllib.parse import quote from toga_cocoa.libs import NSURL, NSDocument, objc_method, objc_property @@ -20,13 +21,16 @@ def readFromFileWrapper_ofType_error_( class Document: + # macOS has multiple documents in a single app instance. + SINGLE_DOCUMENT_APP = False + def __init__(self, interface): self.native = TogaDocument.alloc() self.native.interface = interface self.native.impl = self self.native.initWithContentsOfURL( - NSURL.URLWithString(f"file://{quote(interface.filename)}"), + NSURL.URLWithString(f"file://{quote(os.fsdecode(interface.path))}"), ofType=interface.document_type, error=None, ) diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index 1c8f24e6f4..dbfa29f41e 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -1,4 +1,4 @@ -from .app import App, DocumentApp, MainWindow +from .app import App, DocumentApp, DocumentMainWindow, MainWindow # Resources from .colors import hsl, hsla, rgb, rgba @@ -42,6 +42,7 @@ "App", "DocumentApp", "MainWindow", + "DocumentMainWindow", # Commands "Command", "CommandSet", diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 69cf7a320d..f7a7a6a6e4 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -5,12 +5,13 @@ import warnings import webbrowser from builtins import id as identifier -from collections.abc import MutableSet +from collections.abc import Iterator, MutableSet from email.message import Message from importlib import metadata as importlib_metadata from typing import Any, Iterable, Protocol from toga.command import CommandSet +from toga.documents import Document from toga.handlers import wrapped_handler from toga.icons import Icon from toga.paths import Paths @@ -70,51 +71,58 @@ def __call__(self, app: App, **kwargs: Any) -> None: class WindowSet(MutableSet): - """A collection of windows managed by an app. + def __init__(self, app: App): + """A collection of windows managed by an app. - A window can be added to app by using `app.windows.add(toga.Window(...))` or - `app.windows += toga.Window(...)` notations. Adding a window to app automatically - sets `window.app` property to the app. - """ + A window is automatically added to the app when it is shown. Alternatively, the + window can be explicitly added to the app (without being shown) using + ``app.windows.add(toga.Window(...))`` or ``app.windows += toga.Window(...)``. + Adding a window to an App's window set automatically sets the + :attr:`~toga.Window.app` property of the Window. - def __init__(self, app: App, iterable: Iterable[Window] = ()): + :param app: The app maintaining the window set. + """ self.app = app - self.elements = set(iterable) + self.elements = set() def add(self, window: Window) -> None: + """Add a window to the window set. + + :param window: The :class:`toga.Window` to add + """ if not isinstance(window, Window): - raise TypeError("Toga app.windows can only add objects of toga.Window type") + raise TypeError("Can only add objects of type toga.Window") # Silently not add if duplicate if window not in self.elements: self.elements.add(window) window.app = self.app def discard(self, window: Window) -> None: + """Remove a window from the Window set. + + :param window: The :class:`toga.Window` to remove. + """ if not isinstance(window, Window): - raise TypeError( - "Toga app.windows can only discard an object of a toga.Window type" - ) + raise TypeError("Can only discard objects of type toga.Window") if window not in self.elements: - raise AttributeError( - "The window you are trying to remove is not associated with this app" - ) + raise ValueError(f"{window!r} is not part of this app") self.elements.remove(window) - def __iadd__(self, window): + def __iadd__(self, window: Window) -> None: self.add(window) return self - def __isub__(self, other): + def __isub__(self, other: Window) -> None: self.discard(other) return self - def __iter__(self): + def __iter__(self) -> Iterator: return iter(self.elements) - def __contains__(self, value): + def __contains__(self, value: Window) -> bool: return value in self.elements - def __len__(self): + def __len__(self) -> int: return len(self.elements) @@ -161,8 +169,7 @@ def on_close(self) -> None: 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 for an - App. + :raises ValueError: if an attempt is made to set the ``on_close`` handler. """ return None @@ -170,10 +177,54 @@ def on_close(self) -> None: 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" + "Cannot set on_close handler for the main window. " + "Use the app on_exit handler instead." ) +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 resizeable: 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 + + class App: app = None @@ -191,26 +242,12 @@ def __init__( startup: AppStartupMethod | None = None, windows: Iterable[Window] = (), on_exit: OnExitHandler | None = None, - factory: None = None, # DEPRECATED ! ): - """An App is the top level of any GUI program. - - The App is the manager of all the other aspects of execution. An app will - usually have a main window; this window will hold the widgets with which the - user will interact. - - When you create an App you need to provide a name, an id for uniqueness (by - convention, the identifier is a reversed domain name) and an optional startup - function which should run once the App has initialized. The startup function - constructs the initial user interface. If a startup function is not provided as - an argument, you must subclass the App class and define a ``startup()`` method. + """Create a new App instance. - If the name and app_id are *not* provided, the application will attempt to find - application metadata. This process will determine the module in which the App - class is defined, and look for a ``.dist-info`` file matching that name. - - Once the app is created you should invoke the ``main_loop()`` method, which will - start the event loop of your App. + Once the app has been created, you should invoke the + :meth:`~toga.App.main_loop()` method, which will start the event loop of your + App. :param formal_name: The formal name of the application. Will be derived from packaging metadata if not provided. @@ -231,50 +268,39 @@ class is defined, and look for a ``.dist-info`` file matching that name. :param description: A brief (one line) description of the app. Will be derived from packaging metadata if not provided. :param startup: The callback method before starting the app, typically to add - the components. Must be a ``callable`` that expects a single argument of - :class:`~toga.App`. + the components. :param windows: An iterable with objects of :class:`~toga.Window` that will be the app's secondary windows. """ - - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - # Initialize empty widgets registry self._widgets = WidgetRegistry() - # Keep an accessible copy of the app instance + # Keep an accessible copy of the app singleton instance App.app = self # We need a module name to load app metadata. If an app_name has been # provided, we can set the app name now, and derive the module name # from there. + self._app_name = app_name if app_name: - self._app_name = app_name + metadata_module_name = self.module_name else: - # If the code is contained in appname.py, and you start the app - # using `python -m appname`, the main module package will report - # as ''. Set the initial app name as None. + # If the code is contained in appname.py, and you start the app using + # `python -m appname`, the main module package will report as ''. Set the + # metadata module name as None. # - # If the code is contained in appname.py, and you start the app - # using `python appname.py`, the main module will report as None. + # If the code is contained in appname.py, and you start the app using + # `python appname.py`, the metadata module name will report as None. # - # If the code is contained in a folder, and you start the app - # using `python -m appname`, the main module will report as the - # name of the folder. + # If the code is contained in a folder, and you start the app using `python + # -m appname`, the metadata module name will report as the name of the + # folder. try: main_module_pkg = sys.modules["__main__"].__package__ if main_module_pkg == "": - self._app_name = None + metadata_module_name = None else: - self._app_name = main_module_pkg + metadata_module_name = main_module_pkg except KeyError: # We use the existence of a __main__ module as a proxy for # being in test conditions. This isn't *great*, but the __main__ @@ -282,37 +308,31 @@ class is defined, and look for a ``.dist-info`` file matching that name. # us to avoid having explicit "if under test conditions" checks. # If there's no __main__ module, we're in a test, and we can't # imply an app name from that module name. - self._app_name = None + metadata_module_name = None + + # Try deconstructing the metadata module name from the app ID + if metadata_module_name is None and app_id: + metadata_module_name = app_id.split(".")[-1].replace("-", "_") - # Try deconstructing the app name from the app ID - if self._app_name is None and app_id: - self._app_name = app_id.split(".")[-1] + # If we still don't have a metadata module name, fall back to ``toga`` as a + # last resort. + if metadata_module_name is None: + metadata_module_name = "toga" - # Load the app metadata (if it is available) - # Apps packaged with Briefcase will have this metadata. + # Try to load the app metadata with our best guess of the module name. try: - self.metadata = importlib_metadata.metadata(self.module_name) + self.metadata = importlib_metadata.metadata(metadata_module_name) except importlib_metadata.PackageNotFoundError: self.metadata = Message() - # Now that we have metadata, we can fix the app name (in the case - # where the app name and the module name differ - e.g., an app name - # of ``hello-world`` will have a module name of ``hello_world``). - # We use the PEP566-compliant key ``Name```, rather than the internally - # consistent key ``App-Name```. - if self.metadata["Name"] is not None: - self._app_name = self.metadata["Name"] - - # Whatever app name has been given, speculatively attempt to import - # the app module. Single-file apps won't have an app folder; apps with - # misleading or misconfigured app names haven't given us enough - # metadata to determine the app folder. In those cases, fall back to - # an app name that *will* exist (``toga```) - try: - sys.modules[self.module_name] - except KeyError: - # Well that didn't work... - self._app_name = "toga" + # If the app name wasn't explicitly provided, look to the app metadata. If the + # metadata provides a "Name" key, use that as the app name; otherwise, fall back + # to the metadata module name (which might be "toga") + if app_name is None: + if "Name" in self.metadata: + self._app_name = self.metadata["Name"] + else: + self._app_name = metadata_module_name # If a name has been provided, use it; otherwise, look to # the module metadata. However, a name *must* be provided. @@ -383,27 +403,31 @@ class is defined, and look for a ``.dist-info`` file matching that name. self._startup_method = startup self._main_window = None - self.windows = WindowSet(self, windows) + self.windows = WindowSet(self) self._full_screen_windows = None - self._impl = self._create_impl() + self._create_impl() + + for window in windows: + self.windows.add(window) + self.on_exit = on_exit def _create_impl(self): - return self.factory.App(interface=self) + self.factory.App(interface=self) @property def paths(self) -> Paths: """Paths for platform appropriate locations on the user's file system. - Some platforms do not allow arbitrary file access to any location on - disk; even when arbitrary file system access is allowed, there are - "preferred" locations for some types of content. + Some platforms do not allow arbitrary file access to any location on disk; even + when arbitrary file system access is allowed, there are "preferred" locations + for some types of content. - The :class:`~toga.paths.Paths` object has a set of sub-properties that - return :class:`pathlib.Path` instances of platform-appropriate paths on - the file system. + The :class:`~toga.paths.Paths` object has a set of sub-properties that return + :class:`pathlib.Path` instances of platform-appropriate paths on the file + system. """ return self._paths @@ -425,19 +449,13 @@ def app_name(self) -> str: @property def module_name(self) -> str | None: """The module name for the app.""" - try: - return self._app_name.replace("-", "_") - except AttributeError: - # If the app was created from an interactive prompt, - # there won't be a module name. - return None + return self._app_name.replace("-", "_") @property def app_id(self) -> str: """The identifier for the app. - This is a reversed domain name, often used for targeting resources, - etc. + This is a reversed domain name, often used for targeting resources, etc. """ return self._app_id @@ -471,7 +489,11 @@ def id(self) -> str: @property def icon(self) -> Icon: - """The Icon for the app.""" + """The Icon for the app. + + When setting the icon, you can provide either an icon instance, or a string that + will be resolved as an Icon resource name. + """ return self._icon @icon.setter @@ -501,15 +523,15 @@ def main_window(self, window: MainWindow) -> None: self._impl.set_main_window(window) @property - def current_window(self): + def current_window(self) -> Window | None: """Return the currently active content window.""" window = self._impl.get_current_window() if window is None: - return window + return None return window.interface @current_window.setter - def current_window(self, window): + def current_window(self, window: Window): """Set a window into current active focus.""" self._impl.set_current_window(window) @@ -542,7 +564,7 @@ def exit_full_screen(self) -> None: self._full_screen_windows = None def show_cursor(self) -> None: - """Show cursor.""" + """Make the cursor visible.""" self._impl.show_cursor() def hide_cursor(self) -> None: @@ -550,7 +572,12 @@ def hide_cursor(self) -> None: self._impl.hide_cursor() def startup(self) -> None: - """Create and show the main window for the application.""" + """Create and show the main window for the application. + + Subclasses can override this method to define customized startup behavior; + however, as a result of invoking this method, the app *must* have a + ``main_window``. + """ self.main_window = MainWindow(title=self.formal_name) if self._startup_method: @@ -602,8 +629,12 @@ def main_loop(self) -> None: self._impl.main_loop() def exit(self) -> None: - """Quit the application gracefully.""" - self.on_exit(None) + """Exit the application gracefully. + + This *does not* invoke the ``on_exit`` handler; the app will be immediately + and unconditionally closed. + """ + self._impl.exit() @property def on_exit(self) -> OnExitHandler: @@ -612,14 +643,9 @@ def on_exit(self) -> OnExitHandler: @on_exit.setter def on_exit(self, handler: OnExitHandler | None) -> None: - if handler is None: - - def handler(app, *args, **kwargs): - app._impl.exit() - def cleanup(app, should_exit): - if should_exit: - app._impl.exit() + if should_exit or handler is None: + app.exit() self._on_exit = wrapped_handler(self, handler, cleanup=cleanup) @@ -650,29 +676,26 @@ def __init__( home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, - document_types: list[str] | None = None, + document_types: dict[str, type[Document]] = None, on_exit: OnExitHandler | None = None, - factory: None = None, # DEPRECATED ! ): - """Create a document-based Application. + """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 have it's own window. + exception that there is no main window. Instead, each document managed by the + app will create and manage it's own window (or windows). + + :param document_types: A dictionary of file extensions of document types that + the application can managed, mapping to the :class:`toga.Document` subclass + that will be created when a document of with that extension is opened. The + :class:`toga.Document` subclass must take exactly 2 arguments in it's + constructor: ``path`` and ``app`` - :param document_types: The file extensions that this application can manage. """ - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - - self.document_types = document_types + 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__( @@ -697,6 +720,38 @@ def _verify_startup(self): pass @property - def documents(self) -> list[str]: + def document_types(self) -> dict[str, type[Document]]: + """The document types this app can manage. + + This is a dictionary of file extensions mapping to the Document class that will + be created when a document of with that extension is opened. This class will + usually be a subclass of :class:`toga.Document`.toga.Document`. + """ + 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 presere 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() diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index 4604314a32..271a5c6006 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -1,16 +1,109 @@ -class Document: - def __init__(self, filename, document_type, app=None): - self.filename = filename - self.document_type = document_type +from __future__ import annotations + +import asyncio +import warnings +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from toga.app import App + + +class Document(ABC): + def __init__( + self, + path: str | Path, + document_type: str, + app: App = None, + ): + """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 + # Create the visual representation of the document + self.create() + # 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 + implementation to provide protection against losing unsaved document changes, or + other close-preventing behavior. + + 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.""" + 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 app(self): + def document_type(self) -> Path: + """A human-readable description of the document type.""" + return self._document_type + + @property + def app(self) -> App: + """The app that this document is associated with.""" return self._app - def read(self): - raise NotImplementedError("Document class must define read()") + def show(self) -> None: + """Show the main_window for this document.""" + self.main_window.show() + + @abstractmethod + def create(self) -> None: + """Create the window (or window) containers for the document. + + This must, at a minimum, assign a ``main_window`` property to the document. It + may create additional windows or visual representations, if desired. + """ + + @abstractmethod + def read(self) -> None: + """Load a representation of the document into memory and populate the document + window.""" diff --git a/core/tests/app/__init__.py b/core/tests/app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py new file mode 100644 index 0000000000..2722fc5b50 --- /dev/null +++ b/core/tests/app/test_app.py @@ -0,0 +1,647 @@ +import asyncio +import sys +import webbrowser +from importlib import metadata as importlib_metadata +from unittest.mock import Mock + +import pytest + +import toga +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) + +EXPLICIT_FULL_APP_KWARGS = dict( + formal_name="Explicit App", + app_id="org.beeware.explicit-app", + app_name="override-app", +) +EXPLICIT_MIN_APP_KWARGS = dict( + formal_name="Explicit App", + app_id="org.beeware.explicit-app", +) +APP_METADATA = { + "Formal-Name": "Test App", + "App-ID": "org.beeware.test-app", + "Name": "test-app", +} + + +@pytest.fixture +def app(): + return toga.App(formal_name="Test App", app_id="org.example.test") + + +@pytest.mark.parametrize( + ( + "kwargs, metadata, main_module, expected_formal_name, expected_app_id, expected_app_name, " + "expected_initial_module_name, expected_module_name" + ), + [ + ########################################################################### + # Invoking as python my_app.py, or as an interactive prompt + # This causes a main package of None + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + Mock(__package__=None), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + # Explicit app properties, but implied app name from app_id, no metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + Mock(__package__=None), + "Explicit App", + "org.beeware.explicit-app", + "explicit_app", + "explicit_app", + "explicit_app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + Mock(__package__=None), + "Test App", + "org.beeware.test-app", + "test-app", + "toga", + "test_app", + ), + # Explicit app properties, with metadata. + # Initial data will be derived by reading the metadata from the original app + # name, but this value will be overridden by the metadata. This is an unlikely, + # but theoretically possible scenario. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + Mock(__package__=None), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + ########################################################################### + # Invoking as python -m my_app, where code is in my_app.py + # This causes a main module of "" + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + Mock(__package__=""), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + # Explicit app properties, but implied app name from app_id, no metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + Mock(__package__=""), + "Explicit App", + "org.beeware.explicit-app", + "explicit_app", + "explicit_app", + "explicit_app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + Mock(__package__=""), + "Test App", + "org.beeware.test-app", + "test-app", + "toga", + "test_app", + ), + # Explicit app properties, with metadata. + # Initial data will be derived by reading the metadata from the original app + # name, but this value will be overridden by the metadata. This is an unlikely, + # but theoretically possible scenario. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + Mock(__package__=""), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + ########################################################################### + # Invoking as python -m my_app, where my_app is a folder with a __main__ + # This causes a main module of "my_app" + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + Mock(__package__="my_app"), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + # Explicit app properties, but implied app name from app_id, no metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + Mock(__package__="my_app"), + "Explicit App", + "org.beeware.explicit-app", + "my_app", + "my_app", + "my_app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + Mock(__package__="my_app"), + "Test App", + "org.beeware.test-app", + "test-app", + "my_app", + "test_app", + ), + # Explicit app properties, with metadata. + # Initial data will be derived by reading the metadata from the original app + # name. This can happen if the app metadata doesn't match the package name. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + Mock(__package__="my_app"), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + ########################################################################### + # Invoking in a test harness, where there's no __main__ + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + None, + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + # Explicit app properties, but implied app name from app_id, no metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + None, + "Explicit App", + "org.beeware.explicit-app", + "explicit_app", + "explicit_app", + "explicit_app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + None, + "Test App", + "org.beeware.test-app", + "test-app", + "toga", + "test_app", + ), + # Explicit app properties, with metadata. Explicit values take precedence. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + None, + "Explicit App", + "org.beeware.explicit-app", + "override-app", + "override_app", + "override_app", + ), + ], +) +def test_create( + monkeypatch, + kwargs, + metadata, + main_module, + expected_formal_name, + expected_app_id, + expected_app_name, + expected_initial_module_name, + expected_module_name, +): + """A simple app can be created""" + # Monkeypatch the metadata retrieval function + if metadata: + metadata_mock = Mock(return_value=metadata) + else: + metadata_mock = Mock( + side_effect=importlib_metadata.PackageNotFoundError( + expected_initial_module_name + ) + ) + monkeypatch.setattr(importlib_metadata, "metadata", metadata_mock) + + # Monkeypatch the main module + if main_module is None: + try: + monkeypatch.delitem(sys.modules, "__main__") + except KeyError: + pass + else: + monkeypatch.setitem(sys.modules, "__main__", main_module) + + app = toga.App(**kwargs) + + assert app.formal_name == expected_formal_name + assert app.name == expected_formal_name + assert app.app_id == expected_app_id + assert app.app_name == expected_app_name + assert app.module_name == expected_module_name + assert app.on_exit._raw is None + + metadata_mock.assert_called_once_with(expected_initial_module_name) + + +@pytest.mark.parametrize( + "kwargs, message", + [ + (dict(), "Toga application must have a formal name"), + (dict(formal_name="Something"), "Toga application must have an App ID"), + ], +) +def test_bad_app_creation(kwargs, message): + """Errors are raised""" + with pytest.raises(RuntimeError, match=message): + toga.App(**kwargs) + + +def test_app_metadata(monkeypatch): + """An app can load metadata from the .dist-info file""" + monkeypatch.setattr( + importlib_metadata, + "metadata", + Mock( + return_value={ + "Formal-Name": "Metadata Name", + "Name": "metadata", + "App-ID": "org.beeware.metadata", + "Author": "Jane Developer", + "Version": "1.2.3", + "Home-page": "https://example.com/test-app", + "Summary": "A test app", + } + ), + ) + + # We can't use the app fixture here, because we need the metadata to be loaded as + # part of app construction. + app = toga.App( + formal_name="Test App", + app_id="org.example.test-app", + ) + + assert app.id == str(id(app)) + assert app.author == "Jane Developer" + assert app.version == "1.2.3" + assert app.home_page == "https://example.com/test-app" + assert app.description == "A test app" + + +def test_explicit_app_metadata(monkeypatch): + """App metadata can be provided explicitly, overriding module-level metadata""" + monkeypatch.setattr( + importlib_metadata, + "metadata", + Mock( + return_value={ + "Formal-Name": "Metadata Name", + "Name": "metadata", + "App-ID": "org.beeware.metadata", + "Author": "Alice Metadata", + "Version": "2.3.4", + "Home-page": "https://example.com/metadata", + "Summary": "Metadata description of app", + } + ), + ) + + on_exit_handler = Mock() + + app = toga.App( + id="testapp-id", + formal_name="Test App", + app_id="org.example.test-app", + author="Jane Developer", + version="1.2.3", + home_page="https://example.com/test-app", + description="A test app", + on_exit=on_exit_handler, + ) + + assert app.id == "testapp-id" + assert app.author == "Jane Developer" + assert app.version == "1.2.3" + assert app.home_page == "https://example.com/test-app" + assert app.description == "A test app" + + assert app.on_exit._raw == on_exit_handler + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon_construction(construct): + """The app icon can be set during construction""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + app = toga.App( + formal_name="Test App", + app_id="org.example.test", + icon=icon, + ) + + # Default icon matches app name + assert isinstance(app.icon, toga.Icon) + assert app.icon.path == "path/to/icon" + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon(app, construct): + """The app icon can be changed""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + # Default icon matches app name + assert isinstance(app.icon, toga.Icon) + assert app.icon.path == "resources/test" + + # Change icon + app.icon = icon + + # Default icon matches app name + assert isinstance(app.icon, toga.Icon) + assert app.icon.path == "path/to/icon" + + +def test_current_window(): + """The current window can be set and changed.""" + window1 = toga.Window() + window2 = toga.Window() + app = toga.App( + formal_name="Test App", app_id="org.example.test", windows=[window1, window2] + ) + # There are three windows - the 2 provided, plus the main window + assert len(app.windows) == 3 + assert_action_performed_with(app, "set_main_window", window=app.main_window) + + # The initial current window is the main window + assert app.current_window == app.main_window + + # Change the current window + app.current_window = window1 + assert app.current_window == window1 + assert_action_performed_with(app, "set_current_window", window=window1) + + +def test_no_current_window(app): + """If there's no current window, current_window reflects this""" + # If all the windows are deleted, and there's no main window (e.g., if it's a document app) + # there might be no current window. + app._main_window = None + + # The current window evaluates as None + assert app.current_window is None + + +def test_full_screen(): + """The app can be put into full screen mode.""" + window1 = toga.Window() + window2 = toga.Window() + app = toga.App( + formal_name="Test App", app_id="org.example.test", windows=[window1, window2] + ) + + assert not app.is_full_screen + + # If we're not full screen, exiting full screen is a no-op + app.exit_full_screen() + assert_action_not_performed(app, "exit_full_screen") + + # Enter full screen with 2 windows + app.set_full_screen(window2, app.main_window) + assert app.is_full_screen + assert_action_performed_with( + app, "enter_full_screen", windows=(window2, app.main_window) + ) + + # Change the screens that are full screen + app.set_full_screen(app.main_window, window1) + assert app.is_full_screen + assert_action_performed_with( + app, "enter_full_screen", windows=(app.main_window, window1) + ) + + # Exit full screen mode + app.exit_full_screen() + assert not app.is_full_screen + assert_action_performed_with( + app, "exit_full_screen", windows=(app.main_window, window1) + ) + + +def test_set_empty_full_screen_window_list(): + """Setting the full screen window list to [] is an explicit exit""" + window1 = toga.Window() + window2 = toga.Window() + app = toga.App( + formal_name="Test App", app_id="org.example.test", windows=[window1, window2] + ) + + assert not app.is_full_screen + + # Change the screens that are full screen + app.set_full_screen(window1, window2) + assert app.is_full_screen + assert_action_performed_with(app, "enter_full_screen", windows=(window1, window2)) + + # Exit full screen mode by setting no windows full screen + app.set_full_screen() + assert not app.is_full_screen + assert_action_performed_with(app, "exit_full_screen", windows=(window1, window2)) + + +def test_show_hide_cursor(app): + """The app cursor can be shown and hidden""" + app.hide_cursor() + assert_action_performed(app, "hide_cursor") + + app.show_cursor() + assert_action_performed(app, "show_cursor") + + +def test_startup_method(): + """If an app provides a startup method, it will be invoked during startup""" + startup = Mock() + app = toga.App( + formal_name="Test App", + app_id="org.example.test", + startup=startup, + ) + + startup.assert_called_once_with(app) + + +def test_startup_subclass(): + """App can be subclassed""" + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = toga.MainWindow() + + app = SubclassedApp(formal_name="Test App", app_id="org.example.test") + + # The main window will exist, and will have the app's formal name. + assert app.main_window.title == "Test App" + + +def test_startup_subclass_no_main_window(): + """If a subclassed app doesn't define a main window, an error is raised""" + + class SubclassedApp(toga.App): + def startup(self): + pass + + with pytest.raises(ValueError, match=r"Application does not have a main window."): + SubclassedApp(formal_name="Test App", app_id="org.example.test") + + +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(monkeypatch): + """The app's homepage 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 no homepage by default, so visit is a no-op + app.visit_homepage() + + open_webbrowser.assert_called_once_with("https://example.com/test-app") + + +def test_no_homepage(monkeypatch, app): + """If the app doesn't have a home page, visit_homepage is a no-op""" + open_webbrowser = Mock() + monkeypatch.setattr(webbrowser, "open", open_webbrowser) + + # The app has no homepage by default, so visit is a no-op + app.visit_homepage() + + open_webbrowser.assert_not_called() + + +def test_beep(app): + """The machine can go Bing!""" + app.beep() + assert_action_performed(app, "beep") + + +def test_exit_direct(app): + """An app can be exited directly""" + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + # Exit the app directly + app.exit() + + # App has been exited, but the exit handler has *not* been invoked. + assert_action_performed(app, "exit") + on_exit_handler.assert_not_called() + + +def test_exit_no_handler(app): + """A app without a exit handler can be exited""" + # Exit the app + app._impl.simulate_exit() + + # Window has been exitd, and is no longer in the app's list of windows. + assert_action_performed(app, "exit") + + +def test_exit_sucessful_handler(app): + """An app with a successful exit handler can be exited""" + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + # Close the app + app._impl.simulate_exit() + + # App has been exited + assert_action_performed(app, "exit") + on_exit_handler.assert_called_once_with(app) + + +def test_exit_rejected_handler(app): + """An app can have a exit handler that rejects the exit""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Close the window + app._impl.simulate_exit() + + # App has been *not* exited + assert_action_not_performed(app, "exit") + on_exit_handler.assert_called_once_with(app) + + +def test_background_task(app): + """A mbackground task can be queued""" + canary = Mock() + + async def background(app, **kwargs): + canary() + + app.add_background_task(background) + + # Create an async task that we can use to start the event loop for a short time. + async def waiter(): + await asyncio.sleep(0.1) + + app._impl.loop.run_until_complete(waiter()) + + # Once the loop has executed, the background task should have executed as well. + canary.assert_called_once() diff --git a/core/tests/app/test_documentapp.py b/core/tests/app/test_documentapp.py new file mode 100644 index 0000000000..f4c53af086 --- /dev/null +++ b/core/tests/app/test_documentapp.py @@ -0,0 +1,158 @@ +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 new file mode 100644 index 0000000000..99261279ad --- /dev/null +++ b/core/tests/app/test_mainwindow.py @@ -0,0 +1,46 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga_dummy.utils import assert_action_performed + + +@pytest.fixture +def app(): + return toga.App("Test App", "org.beeware.toga.app.main_window") + + +def test_create(app): + "A MainWindow can be created with minimal arguments" + window = toga.MainWindow() + + assert window.app is None + 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/app/test_windowset.py b/core/tests/app/test_windowset.py new file mode 100644 index 0000000000..9a69270081 --- /dev/null +++ b/core/tests/app/test_windowset.py @@ -0,0 +1,118 @@ +import pytest + +import toga +from toga.app import WindowSet + + +@pytest.fixture +def app(): + return toga.App("Test App", "org.beeware.toga.app.main_window") + + +@pytest.fixture +def window1(): + return toga.Window(title="Window 1") + + +@pytest.fixture +def window2(): + return toga.Window(title="Window 2") + + +@pytest.fixture +def window3(): + return toga.Window(title="Window 3") + + +def test_create(app): + """An empty windowset can be created.""" + windowset = WindowSet(app) + + assert windowset.app == app + assert len(windowset) == 0 + + +def test_add_discard(app, window1, window2, window3): + """An item can be added to a windowset""" + windowset = WindowSet(app) + windowset.add(window1) + windowset.add(window2) + assert len(windowset) == 2 + # Check the iterator works + assert set(iter(windowset)) == {window1, window2} + + with pytest.raises( + TypeError, + match=r"Can only add objects of type toga.Window", + ): + windowset.add(object()) + + windowset.add(window3) + assert len(windowset) == 3 + assert window3 in windowset + assert window3.app == app + + # Re-add the same window + windowset.add(window3) + assert len(windowset) == 3 + assert window3 in windowset + assert window3.app == app + + # Discard the window + windowset.discard(window3) + assert window3 not in windowset + + # Duplicate discard - it's no longer a member + with pytest.raises( + ValueError, + match=r" is not part of this app", + ): + windowset.discard(window3) + + with pytest.raises( + TypeError, + match=r"Can only discard objects of type toga.Window", + ): + windowset.discard(object()) + + +def test_add_discard_by_operator(app, window1, window2, window3): + """An item can be added to a windowset by inline operators""" + windowset = WindowSet(app) + windowset.add(window1) + windowset.add(window2) + assert len(windowset) == 2 + + with pytest.raises( + TypeError, + match=r"Can only add objects of type toga.Window", + ): + windowset += object() + + windowset += window3 + assert len(windowset) == 3 + assert window3 in windowset + assert window3.app == app + + # Re-add the same window + windowset += window3 + assert len(windowset) == 3 + assert window3 in windowset + assert window3.app == app + + # Discard the window + windowset -= window3 + assert window3 not in windowset + + # Duplicate discard - it's no longer a member + with pytest.raises( + ValueError, + match=r" is not part of this app", + ): + windowset -= window3 + + with pytest.raises( + TypeError, + match=r"Can only discard objects of type toga.Window", + ): + windowset -= object() diff --git a/core/tests/test_app.py b/core/tests/test_app.py deleted file mode 100644 index 0f646be4cf..0000000000 --- a/core/tests/test_app.py +++ /dev/null @@ -1,224 +0,0 @@ -import asyncio -from unittest.mock import Mock - -import toga -from toga.widgets.base import WidgetRegistry -from toga_dummy.utils import TestCase - - -class AppTests(TestCase): - def setUp(self): - super().setUp() - - self.name = "Test App" - self.app_id = "org.beeware.test-app" - self.id = "dom-id" - - self.content = Mock() - self.content_id = "content-id" - self.content.id = self.content_id - - self.started = False - - def test_startup_function(app): - self.started = True - return self.content - - self.app = toga.App( - formal_name=self.name, - app_id=self.app_id, - startup=test_startup_function, - id=self.id, - ) - - def test_app_name(self): - self.assertEqual(self.app.name, self.name) - - def test_app_icon(self): - # App icon will default to a name autodetected from the running module - self.assertEqual(self.app.icon.path, "resources/toga") - - # This icon will be bound - self.assertIsNotNone(self.app.icon._impl) - - # Binding is a no op. - with self.assertWarns(DeprecationWarning): - self.app.icon.bind() - self.assertIsNotNone(self.app.icon._impl) - - # Set the icon to a different resource - self.app.icon = "other.icns" - self.assertEqual(self.app.icon.path, "other.icns") - - # This icon name will *not* exist. The Impl will be the DEFAULT_ICON's impl - self.assertEqual(self.app.icon._impl, toga.Icon.DEFAULT_ICON._impl) - - def test_app_app_id(self): - self.assertEqual(self.app.app_id, self.app_id) - - def test_app_id(self): - self.assertEqual(self.app.id, self.id) - - def test_widgets_registry(self): - self.assertTrue(isinstance(self.app.widgets, WidgetRegistry)) - self.assertEqual(len(self.app.widgets), 0) - - def test_app_main_loop_call_impl_main_loop(self): - self.app.main_loop() - self.assertActionPerformed(self.app, "main loop") - - def test_app_startup(self): - self.app.startup() - - self.assertTrue(self.started) - self.assertEqual(self.app.main_window.content, self.content) - self.assertEqual(self.app.main_window.app, self.app) - self.assertActionPerformed(self.app.main_window, "show") - - def test_is_full_screen(self): - self.assertFalse(self.app.is_full_screen) - - self.app.set_full_screen(self.app.main_window) - self.assertTrue(self.app.is_full_screen) - - self.app.set_full_screen(["window1", "window2", "window3"]) - self.assertTrue(self.app.is_full_screen) - - self.app.set_full_screen() - self.assertFalse(self.app.is_full_screen) - - def test_app_exit(self): - def exit_handler(widget): - return True - - self.app.on_exit = exit_handler - self.assertIs(self.app.on_exit._raw, exit_handler) - self.app.exit() - - self.assertActionPerformed(self.app, "exit") - - def test_full_screen(self): - # set full screen and exit full screen - self.app.set_full_screen(self.app.main_window) - self.assertTrue(self.app.is_full_screen) - self.app.exit_full_screen() - self.assertFalse(self.app.is_full_screen) - # set full screen and set full with no args - self.app.set_full_screen(self.app.main_window) - self.assertTrue(self.app.is_full_screen) - self.app.set_full_screen() - self.assertFalse(self.app.is_full_screen) - - def test_add_window(self): - test_window = toga.Window() - - self.assertEqual(len(self.app.windows), 0) - self.app.windows += test_window - self.assertEqual(len(self.app.windows), 1) - self.app.windows += test_window - self.assertEqual(len(self.app.windows), 1) - self.assertIs(test_window.app, self.app) - - not_a_window = "not_a_window" - with self.assertRaises(TypeError): - self.app.windows += not_a_window - - def test_remove_window(self): - test_window = toga.Window() - self.app.windows += test_window - self.assertEqual(len(self.app.windows), 1) - self.app.windows -= test_window - self.assertEqual(len(self.app.windows), 0) - - not_a_window = "not_a_window" - with self.assertRaises(TypeError): - self.app.windows -= not_a_window - - test_window_not_in_app = toga.Window() - with self.assertRaises(AttributeError): - self.app.windows -= test_window_not_in_app - - def test_app_contains_window(self): - test_window = toga.Window() - self.assertFalse(test_window in self.app.windows) - self.app.windows += test_window - self.assertTrue(test_window in self.app.windows) - - def test_window_iteration(self): - test_windows = [ - toga.Window(id=1), - toga.Window(id=2), - toga.Window(id=3), - ] - for window in test_windows: - self.app.windows += window - self.assertEqual(len(self.app.windows), 3) - - for window in self.app.windows: - self.assertIn(window, test_windows) - - def test_beep(self): - self.app.beep() - self.assertActionPerformed(self.app, "beep") - - def test_add_background_task(self): - thing = Mock() - - async def test_handler(sender): - thing() - - self.app.add_background_task(test_handler) - - async def run_test(): - # Give the background task time to run. - await asyncio.sleep(0.1) - thing.assert_called_once() - - self.app._impl.loop.run_until_complete(run_test()) - - def test_override_startup(self): - class BadApp(toga.App): - "A startup method that doesn't assign main window raises an error (#760)" - - def startup(self): - # Override startup but don't create a main window - pass - - app = BadApp(app_name="bad_app", formal_name="Bad Aoo", app_id="org.beeware") - with self.assertRaisesRegex( - ValueError, - r"Application does not have a main window.", - ): - app.main_loop() - - -class DocumentAppTests(TestCase): - def setUp(self): - super().setUp() - - self.name = "Test Document App" - self.app_id = "beeware.org" - self.id = "id" - - self.content = Mock() - - self.app = toga.DocumentApp(self.name, self.app_id, id=self.id) - - def test_app_documents(self): - self.assertEqual(self.app.documents, []) - - doc = Mock() - self.app._documents.append(doc) - self.assertEqual(self.app.documents, [doc]) - - def test_override_startup(self): - mock = Mock() - - class DocApp(toga.DocumentApp): - def startup(self): - # A document app doesn't have to provide a Main Window. - mock() - - app = DocApp(app_name="docapp", formal_name="Doc App", app_id="org.beeware") - app.main_loop() - mock.assert_called_once() diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 47f6ee59ef..e517bc6ba0 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -14,22 +14,6 @@ def setUp(self): ###################################################################### # factory no longer used - def test_app(self): - with self.assertWarns(DeprecationWarning): - widget = toga.App( - formal_name="Test", app_id="org.beeware.test-app", factory=self.factory - ) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - - def test_document_app(self): - with self.assertWarns(DeprecationWarning): - widget = toga.DocumentApp( - formal_name="Test", app_id="org.beeware.test-app", factory=self.factory - ) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_command(self): with self.assertWarns(DeprecationWarning): widget = toga.Command(self.callback, "Test", factory=self.factory) @@ -49,13 +33,6 @@ def test_font(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_icon(self): - widget = toga.Icon("resources/toga", system=True) - with self.assertWarns(DeprecationWarning): - widget.bind(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_canvas_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Canvas(factory=self.factory) diff --git a/core/tests/test_documents.py b/core/tests/test_documents.py index cb00665d14..b962a51a9b 100644 --- a/core/tests/test_documents.py +++ b/core/tests/test_documents.py @@ -1,22 +1,57 @@ +from pathlib import Path + +import pytest + import toga -import toga_dummy -from toga_dummy.utils import TestCase -class DocumentTests(TestCase): - def setUp(self): - super().setUp() +class MyDoc(toga.Document): + def __init__(self, path, app): + super().__init__(path, "Dummy Document", app) + pass + + def create(self): + pass + + def read(self): + pass + + +@pytest.fixture +def app(): + return toga.App("Document Test", "org.beeware.toga.documents") + - self.filename = "path/to/document.txt" - self.document_type = "path/to/document.txt" - self.document = toga.Document( - filename=self.filename, document_type=self.document_type, app=toga_dummy +@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 test_app(self): - self.assertEqual(self.filename, self.document.filename) - self.assertEqual(self.document.app, toga_dummy) + def create(self): + pass + + def read(self): + pass + + +def test_deprecated_names(app): + """Deprecated names still work.""" + doc = MyDeprecatedDoc("/path/to/doc.mydoc", app) - def test_read(self): - with self.assertRaises(NotImplementedError): - self.document.read() + with pytest.warns( + DeprecationWarning, + match=r"Document.filename has been renamed Document.path.", + ): + assert doc.filename == Path("/path/to/doc.mydoc") diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index dc9b7d000e..c051270a7f 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -1,37 +1,53 @@ -Application -=========== +App +=== + +The top-level representation of an application. .. 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 - :exclude: {0: '(?!(App|Component))'} + :exclude: {0: '(?!(Application|Component))'} -The app is the main entry point and container for the Toga GUI. Usage ----- -The app class is used by instantiating with a name, namespace and callback to a startup delegate which takes 1 argument of the app instance. +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 Application. 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. -To start a UI loop, call ``app.main_loop()`` +The application is started by calling :meth:`~toga.App.main_loop()`. This will invoke +the :meth:`~toga.App.startup()` method of the app. .. code-block:: python import toga + app = toga.App("Simplest App", "com.example.simplest") + app.main_loop() - def build(app): - # build UI - pass +You can populate an app's main window by passing a callable as the ``startup`` argument +to the :class:`toga.App` constructor. This ``startup`` method must return the content +that will be added to the main window of the app. +.. code-block:: python - if __name__ == '__main__': - app = toga.App('First App', 'org.beeware.helloworld', startup=build) - app.main_loop() + import toga + + def create_content(app): + return toga.Box(children=[toga.Label("Hello!")]) -Alternatively, you can subclass App and implement the startup method + app = toga.App("Simple App", "com.example.simple", startup=create_content) + app.main_loop() + +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. .. code-block:: python @@ -40,16 +56,39 @@ Alternatively, you can subclass App and implement the startup method class MyApp(toga.App): def startup(self): - # build UI - pass - + self.main_window = toga.MainWindow() + self.main_window.content = toga.Box(children=[toga.Label("Hello!")]) + self.main_window.show() if __name__ == '__main__': - app = MyApp('First App', 'org.beeware.helloworld') + app = MyApp("Realistic App", "org.beeware.realistic") app.main_loop() -All App instances must have a main window. This main window must exist at the conclusion -of the ``startup()`` method. +When creating an app, you must provide a formal name (a human readable name for the +app), and an App ID (a machine-readable identifier - usually a reversed domain name). +You can provide these details as explicit arguments; however, you can also provide these +details as PEP621 packaging metadata using the ``Formal-Name`` and ``App-ID`` keys. If +you deploy your app with `Briefcase `__, +this metadata will be populated as part of the deployment process. + +A Toga app also has an app name; this is a `PEP508 +`__ module identifier for the app. The app name +can be provided explicitly; however, if it isn't provided explicitly, Toga uses the +following strategy to determine an app name: + +1. If an app name has been explicitly provided, it will be used as-is. +2. If no app name has been explicitly provided, Toga will look for the name of the + parent of the ``__main__`` module for the app. +3. If there is no ``__main__`` module, but an App ID has been explicitly provided, the + last name part of the App ID will be used. For example, an explicit App ID of + ``com.example.my-app`` would yield an app name of ``my-app``. +4. As a last resort, Toga will use the name ``toga`` as an app name. + +Toga will attempt to load an :class:`~toga.Icon` for the app. If an icon is not +specified when the App instance is created, Toga will attempt to use ``resources/`` as the name of the icon (for whatever app name has been provided or derived). If +no resource matching this name can be found, a warning will be printed, and the app will +fall back to a default icon. Reference --------- @@ -58,6 +97,10 @@ Reference :members: :undoc-members: +.. autoclass:: toga.app.WindowSet + :members: + :undoc-members: + .. autoprotocol:: toga.app.AppStartupMethod .. autoprotocol:: toga.app.BackgroundTask .. autoprotocol:: toga.app.OnExitHandler diff --git a/docs/reference/api/documentapp.rst b/docs/reference/api/documentapp.rst new file mode 100644 index 0000000000..966fc17bb0 --- /dev/null +++ b/docs/reference/api/documentapp.rst @@ -0,0 +1,90 @@ +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 + :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/index.rst b/docs/reference/api/index.rst index 41968d8568..ce1015e988 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -7,13 +7,14 @@ API Reference Core application components --------------------------- -=============================================== =================================================== - Component Description -=============================================== =================================================== - :doc:`Application ` The application itself - :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:`DocumentApp ` An application that manages documents. + :doc:`Window ` An operating system-managed container of widgets. + :doc:`MainWindow ` The main window of the application. +================================================= =================================================== General widgets --------------- @@ -100,8 +101,9 @@ Other :hidden: app - mainwindow + documentapp window + mainwindow containers/index resources/index widgets/index diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 7f7804cbf3..8b87ae91d3 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -1,5 +1,6 @@ Component,Type,Component,Description,macOS,GTK,Windows,iOS,Android,Web -Application,Core Component,:class:`~toga.App`,The application itself,|b|,|b|,|b|,|b|,|b|,|b| +Application,Core Component,:class:`~toga.App`,The top-level representation of an application.,|b|,|b|,|b|,|b|,|b|,|b| +DocumentApp,Core Component,:class:`~toga.DocumentApp`,An application that manages documents.,|b|,|b|,|b|,|b|,|b|,|b| Window,Core Component,:class:`~toga.Window`,An operating system-managed container of widgets.,|b|,|b|,|b|,|b|,|b|,|b| MainWindow,Core Component,:class:`~toga.MainWindow`,The main window of the application.,|b|,|b|,|b|,|b|,|b|,|b| ActivityIndicator,General Widget,:class:`~toga.ActivityIndicator`,A spinning activity animation,|y|,|y|,,,,|b| diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 27372e7b9e..5a76faf93b 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -41,9 +41,9 @@ parameterization platformer pre programmatically -pytest Pygame PyScript +pytest Quickstart radiusx radiusy @@ -59,18 +59,19 @@ scrollers selectable Stimpy stylesheet +subclasses Subclasses substring substrings testbed -triaged -Triaging Todo toolbar toolbars Toolbars toolkits Towncrier +triaged +Triaging tvOS Ubuntu validator diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 796b490518..ae633c6a59 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -1,33 +1,38 @@ import asyncio +import sys +from pathlib import Path -from .utils import LoggedObject, not_required_on +from .utils import LoggedObject, not_required from .window import Window +@not_required # Coverage is complete class MainWindow(Window): pass +@not_required # Coverage is complete class App(LoggedObject): def __init__(self, interface): super().__init__() self.interface = interface + self.interface._impl = self + self.loop = asyncio.new_event_loop() + self.create() def create(self): - self._action("create") + self._action("create App") self.interface._startup() - @not_required_on("mobile") def create_menus(self): - self._action("create menus") + self._action("create App menus") def main_loop(self): self._action("main loop") - self.create() def set_main_window(self, window): - self._set_value("main_window", window) + self._action("set_main_window", window=window) def show_about_dialog(self): self._action("show_about_dialog") @@ -38,31 +43,40 @@ def beep(self): def exit(self): self._action("exit") - @not_required_on("mobile") def get_current_window(self): - self._action("get_current_window") + try: + return self._get_value("current_window", self.interface.main_window._impl) + except AttributeError: + return None - @not_required_on("mobile") - def set_current_window(self): - self._action("set_current_window") + def set_current_window(self, window): + self._action("set_current_window", window=window) + self._set_value("current_window", window._impl) - @not_required_on("mobile") def enter_full_screen(self, windows): self._action("enter_full_screen", windows=windows) - @not_required_on("mobile") def exit_full_screen(self, windows): self._action("exit_full_screen", windows=windows) - @not_required_on("mobile") def show_cursor(self): self._action("show_cursor") - @not_required_on("mobile") def hide_cursor(self): self._action("hide_cursor") + def simulate_exit(self): + self.interface.on_exit(None) + -@not_required_on("mobile", "web") +@not_required class DocumentApp(App): - pass + def create(self): + self._action("create DocumentApp") + self.interface._startup() + + try: + # Create and show the document instance + self.interface._open(Path(sys.argv[1])) + except IndexError: + pass diff --git a/dummy/src/toga_dummy/documents.py b/dummy/src/toga_dummy/documents.py index a22d2bb254..9de290b969 100644 --- a/dummy/src/toga_dummy/documents.py +++ b/dummy/src/toga_dummy/documents.py @@ -5,3 +5,4 @@ class Document(LoggedObject): def __init__(self, interface): super().__init__() self.interface = interface + self.interface.read() diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 5f7dee07c6..940a7e0007 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -1,8 +1,7 @@ import asyncio -import os -import os.path import signal import sys +from pathlib import Path from urllib.parse import unquote, urlparse import gbulb @@ -265,23 +264,29 @@ def gtk_startup(self, data=None): try: # Look for a filename specified on the command line - file_name = os.path.abspath(sys.argv[1]) + path = Path(sys.argv[1]) except IndexError: # Nothing on the command line; open a file dialog instead. # TODO: This causes a blank window to be shown. # Is there a way to open a file dialog without having a window? m = toga.Window() - file_name = m.select_folder_dialog(self.interface.name, None, False)[0] + path = m.open_file_dialog( + self.interface.name, + file_types=self.interface.document_types.keys(), + ) - self.open_document(file_name) + self.open_document(path) def open_file(self, widget, **kwargs): # TODO: This causes a blank window to be shown. # Is there a way to open a file dialog without having a window? m = toga.Window() - file_name = m.select_folder_dialog(self.interface.name, None, False)[0] + path = m.open_file_dialog( + self.interface.name, + file_types=self.interface.document_types.keys(), + ) - self.open_document(file_name) + self.open_document(path) def open_document(self, fileURL): """Open a new document in this app. @@ -291,12 +296,7 @@ def open_document(self, fileURL): """ # Convert the fileURL to a file path. fileURL = fileURL.rstrip("/") - path = unquote(urlparse(fileURL).path) - extension = os.path.splitext(path)[1][1:] + path = Path(unquote(urlparse(fileURL).path)) # Create the document instance - DocType = self.interface.document_types[extension] - document = DocType(fileURL, self.interface) - self.interface._documents.append(document) - - document.show() + self.interface._open(path) diff --git a/gtk/src/toga_gtk/documents.py b/gtk/src/toga_gtk/documents.py index 6020f29a0b..a42d33f3fa 100644 --- a/gtk/src/toga_gtk/documents.py +++ b/gtk/src/toga_gtk/documents.py @@ -1,4 +1,7 @@ class Document: + # GTK has 1-1 correspondence between document and app instances. + SINGLE_DOCUMENT_APP = True + def __init__(self, interface): self.interface = interface self.interface.read() From 2e1925618fb1e88e748f4560f6c4c04d82df741f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 11 Aug 2023 13:00:31 +0800 Subject: [PATCH 02/66] Add an example DocumentApp --- examples/documentapp/Ironbark.exampledoc | 58 +++++++++++++++++++ examples/documentapp/Jabberwocky.exampledoc | 37 ++++++++++++ examples/documentapp/README.rst | 16 +++++ examples/documentapp/documentapp/__init__.py | 9 +++ examples/documentapp/documentapp/__main__.py | 4 ++ examples/documentapp/documentapp/app.py | 41 +++++++++++++ .../documentapp/documentapp/resources/README | 1 + examples/documentapp/pyproject.toml | 55 ++++++++++++++++++ 8 files changed, 221 insertions(+) create mode 100644 examples/documentapp/Ironbark.exampledoc create mode 100644 examples/documentapp/Jabberwocky.exampledoc create mode 100644 examples/documentapp/README.rst create mode 100644 examples/documentapp/documentapp/__init__.py create mode 100644 examples/documentapp/documentapp/__main__.py create mode 100644 examples/documentapp/documentapp/app.py create mode 100644 examples/documentapp/documentapp/resources/README create mode 100644 examples/documentapp/pyproject.toml diff --git a/examples/documentapp/Ironbark.exampledoc b/examples/documentapp/Ironbark.exampledoc new file mode 100644 index 0000000000..b76fa61f1b --- /dev/null +++ b/examples/documentapp/Ironbark.exampledoc @@ -0,0 +1,58 @@ +The Man from Ironbark +===================== + +It was the man from Ironbark who struck the Sydney town, +He wandered over street and park, he wandered up and down. +He loitered here, he loitered there, till he was like to drop, +Until at last in sheer despair he sought a barber's shop. +"'Ere! shave my beard and whiskers off, I'll be a man of mark, +I'll go and do the Sydney toff up home in Ironbark." + +The barber man was small and flash, as barbers mostly are, +He wore a strike-your-fancy sash, he smoked a huge cigar; +He was a humorist of note and keen at repartee, +He laid the odds and kept a "tote", whatever that may be, +And when he saw our friend arrive, he whispered, "Here's a lark! +Just watch me catch him all alive, this man from Ironbark." + +There were some gilded youths that sat along the barber's wall. +Their eyes were dull, their heads were flat, they had no brains at all; +To them the barber passed the wink, his dexter eyelid shut, +"I'll make this bloomin' yokel think his bloomin' throat is cut." +And as he soaped and rubbed it in he made a rude remark: +"I s'pose the flats is pretty green up there in Ironbark." + +A grunt was all reply he got; he shaved the bushman's chin, +Then made the water boiling hot and dipped the razor in. +He raised his hand, his brow grew black, he paused awhile to gloat, +Then slashed the red-hot razor-back across his victim's throat: +Upon the newly-shaven skin it made a livid mark - +No doubt it fairly took him in - the man from Ironbark. + +He fetched a wild up-country yell might wake the dead to hear, +And though his throat, he knew full well, was cut from ear to ear, +He struggled gamely to his feet, and faced the murd'rous foe: +"You've done for me! you dog, I'm beat! one hit before I go! +I only wish I had a knife, you blessed murdering shark! +But you'll remember all your life the man from Ironbark." + +He lifted up his hairy paw, with one tremendous clout +He landed on the barber's jaw, and knocked the barber out. +He set to work with nail and tooth, he made the place a wreck; +He grabbed the nearest gilded youth, and tried to break his neck. +And all the while his throat he held to save his vital spark, +And "Murder! Bloody murder!" yelled the man from Ironbark. + +A peeler man who heard the din came in to see the show; +He tried to run the bushman in, but he refused to go. +And when at last the barber spoke, and said "'Twas all in fun— +'Twas just a little harmless joke, a trifle overdone." +"A joke!" he cried, "By George, that's fine; a lively sort of lark; +I'd like to catch that murdering swine some night in Ironbark." + +And now while round the shearing floor the list'ning shearers gape, +He tells the story o'er and o'er, and brags of his escape. +"Them barber chaps what keeps a tote, By George, I've had enough, +One tried to cut my bloomin' throat, but thank the Lord it's tough." +And whether he's believed or no, there's one thing to remark, +That flowing beards are all the go way up in Ironbark. diff --git a/examples/documentapp/Jabberwocky.exampledoc b/examples/documentapp/Jabberwocky.exampledoc new file mode 100644 index 0000000000..dfbec58ec1 --- /dev/null +++ b/examples/documentapp/Jabberwocky.exampledoc @@ -0,0 +1,37 @@ +Jabberwocky +=========== + +'Twas brillig, and the slithy toves +Did gyre and gimble in the wabe; +All mimsy were the borogoves, +And the mome raths outgrabe. + +"Beware the Jabberwock, my son! +The jaws that bite, the claws that catch! +Beware the Jubjub bird, and shun +The frumious Bandersnatch!" + +He took his vorpal sword in hand: +Long time the manxome foe he sought— +So rested he by the Tumtum tree, +And stood awhile in thought. + +And as in uffish thought he stood, +The Jabberwock, with eyes of flame, +Came whiffling through the tulgey wood, +And burbled as it came! + +One, two! One, two! And through and through +The vorpal blade went snicker-snack! +He left it dead, and with its head +He went galumphing back. + +"And hast thou slain the Jabberwock? +Come to my arms, my beamish boy! +O frabjous day! Callooh! Callay!" +He chortled in his joy. + +'Twas brillig, and the slithy toves +Did gyre and gimble in the wabe; +All mimsy were the borogoves, +And the mome raths outgrabe. diff --git a/examples/documentapp/README.rst b/examples/documentapp/README.rst new file mode 100644 index 0000000000..b6be1485e7 --- /dev/null +++ b/examples/documentapp/README.rst @@ -0,0 +1,16 @@ +DocumentApp +=========== + +An example DocumentApp. + +Quickstart +~~~~~~~~~~ + +To run this example: + + $ pip install toga + $ python -m documentapp + +Or, pass in a file at the command line + + $ python -m documentapp Jabberwocky.exampledoc diff --git a/examples/documentapp/documentapp/__init__.py b/examples/documentapp/documentapp/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/documentapp/documentapp/__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/documentapp/documentapp/__main__.py b/examples/documentapp/documentapp/__main__.py new file mode 100644 index 0000000000..974a50572c --- /dev/null +++ b/examples/documentapp/documentapp/__main__.py @@ -0,0 +1,4 @@ +from documentapp.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/documentapp/documentapp/app.py b/examples/documentapp/documentapp/app.py new file mode 100644 index 0000000000..6cbb733bd9 --- /dev/null +++ b/examples/documentapp/documentapp/app.py @@ -0,0 +1,41 @@ +import toga + + +class ExampleDocument(toga.Document): + def __init__(self, path, app): + super().__init__(path=path, document_type="Example Document", app=app) + + async def can_close(self): + return await self.main_window.question_dialog( + "Are you sure?", + "Do you want to close this document?", + ) + + def create(self): + # Create the main window for the document. + self.main_window = toga.DocumentMainWindow( + doc=self, + title=f"Example: {self.path.name}", + ) + self.main_window.content = toga.MultilineTextInput() + + def read(self): + with self.path.open() as f: + self.content = f.read() + + self.main_window.content.value = self.content + + +def main(): + return toga.DocumentApp( + "Document App", + "org.beeware.widgets.documentapp", + document_types={ + "exampledoc": ExampleDocument, + }, + ) + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/examples/documentapp/documentapp/resources/README b/examples/documentapp/documentapp/resources/README new file mode 100644 index 0000000000..84f0abfa08 --- /dev/null +++ b/examples/documentapp/documentapp/resources/README @@ -0,0 +1 @@ +Put any icons or images in this directory. diff --git a/examples/documentapp/pyproject.toml b/examples/documentapp/pyproject.toml new file mode 100644 index 0000000000..204f699deb --- /dev/null +++ b/examples/documentapp/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["briefcase"] + +[tool.briefcase] +project_name = "Document App" +bundle = "org.beeware" +version = "0.0.1" +url = "https://beeware.org" +license = "BSD license" +author = "Tiberius Yak" +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.documentapp] +formal_name = "Document App" +description = "A testing app" +sources = ["documentapp"] +requires = [ + "../../core", +] + + +[tool.briefcase.app.documentapp.macOS] +requires = [ + "../../cocoa", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.documentapp.linux] +requires = [ + "../../gtk", +] + +[tool.briefcase.app.documentapp.windows] +requires = [ + "../../winforms", +] + +# Mobile deployments +[tool.briefcase.app.documentapp.iOS] +requires = [ + "../../iOS", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.documentapp.android] +requires = [ + "../../android", +] + +# Web deployment +[tool.briefcase.app.documentapp.web] +requires = [ + "../../web", +] +style_framework = "Shoelace v2.3" From 8f770087fe10390948ea47ed2c91701f42498006 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 11 Aug 2023 13:19:11 +0800 Subject: [PATCH 03/66] Add changenotes. --- changes/2075.feature.1.rst | 1 + changes/2075.removal.1.rst | 1 + changes/2075.removal.2.rst | 1 + changes/2075.removal.3.rst | 1 + 4 files changed, 4 insertions(+) create mode 100644 changes/2075.feature.1.rst create mode 100644 changes/2075.removal.1.rst create mode 100644 changes/2075.removal.2.rst create mode 100644 changes/2075.removal.3.rst diff --git a/changes/2075.feature.1.rst b/changes/2075.feature.1.rst new file mode 100644 index 0000000000..85a69bec32 --- /dev/null +++ b/changes/2075.feature.1.rst @@ -0,0 +1 @@ +App, DocumentApp and Command now have 100% test coverage, and complete API documentation. diff --git a/changes/2075.removal.1.rst b/changes/2075.removal.1.rst new file mode 100644 index 0000000000..acf2ac736e --- /dev/null +++ b/changes/2075.removal.1.rst @@ -0,0 +1 @@ +The ``filename`` argument and property of ``toga.Document`` has been renamed ``path``, and is now guaranteed to be a ``pathlib.Path`` object. diff --git a/changes/2075.removal.2.rst b/changes/2075.removal.2.rst new file mode 100644 index 0000000000..f35a44b70e --- /dev/null +++ b/changes/2075.removal.2.rst @@ -0,0 +1 @@ +Documents must now provide a ``create()`` method to instantiate a ``main_window`` instance. diff --git a/changes/2075.removal.3.rst b/changes/2075.removal.3.rst new file mode 100644 index 0000000000..ec4a3d1aee --- /dev/null +++ b/changes/2075.removal.3.rst @@ -0,0 +1 @@ +``App.exit()`` now unconditionally exits the app, rather than confirming that the ``on_exit`` handler will permit the exit. From d1a64a4d8dd799d5ba3d3f30743bdfe7689ba1d1 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 11 Aug 2023 13:31:31 +0800 Subject: [PATCH 04/66] Correct usage of exit() by main window and quit menu items. --- cocoa/src/toga_cocoa/app.py | 6 +++--- core/src/toga/app.py | 4 ++-- gtk/src/toga_gtk/app.py | 5 +++-- winforms/src/toga_winforms/app.py | 7 +++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index b946f3ddf8..b42fe62e99 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -41,11 +41,11 @@ class MainWindow(Window): def cocoa_windowShouldClose(self): # Main Window close is a proxy for "Exit app". - # Defer all handling to the app's exit method. + # 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.exit() + self.interface.app.on_exit(None) return False @@ -300,7 +300,7 @@ def _menu_about(self, app, **kwargs): self.interface.about() def _menu_exit(self, app, **kwargs): - self.interface.exit() + self.interface.on_exit(None) def _menu_close_window(self, app, **kwargs): if self.interface.current_window: diff --git a/core/src/toga/app.py b/core/src/toga/app.py index f7a7a6a6e4..e34c723ca4 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -398,6 +398,8 @@ def __init__( else: self.icon = f"resources/{self.app_name}" + self.on_exit = on_exit + self.commands = CommandSet() self._startup_method = startup @@ -412,8 +414,6 @@ def __init__( for window in windows: self.windows.add(window) - self.on_exit = on_exit - def _create_impl(self): self.factory.App(interface=self) diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 940a7e0007..cf10209563 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -41,7 +41,8 @@ def gtk_delete_event(self, *args): # closing the window) should be performed; so # "should_exit == True" must be converted to a return # value of False. - return not self.interface.app.exit() + self.interface.app.on_exit(None) + return True class App: @@ -85,7 +86,7 @@ def gtk_startup(self, data=None): Command(None, "Preferences", group=toga.Group.APP), # Quit should always be the last item, in a section on its own Command( - lambda _: self.interface.exit(), + lambda _: self.interface.on_exit(None), "Quit " + self.interface.name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 7a50715fc0..68f85c6f93 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -29,9 +29,8 @@ def winforms_FormClosing(self, sender, event): # 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. - if self.interface.app.on_exit: - self.interface.app.on_exit(self.interface.app) - event.Cancel = True + self.interface.app.on_exit(None) + event.Cancel = True class App: @@ -113,7 +112,7 @@ def create(self): toga.Command(None, "Preferences", group=toga.Group.FILE), # Quit should always be the last item, in a section on its own toga.Command( - lambda _: self.interface.exit(), + lambda _: self.interface.on_exit(None), "Exit " + self.interface.name, shortcut=Key.MOD_1 + "q", group=toga.Group.FILE, From 2dfa2bea60eaf4372dd6da6e6cd99ec421bdeda9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 11 Aug 2023 13:57:58 +0800 Subject: [PATCH 05/66] Correct usage of exit in testbed, and add a pause to ensure logs are flushed. --- testbed/tests/testbed.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index fd8da6b187..a887b7c988 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -2,6 +2,7 @@ import os import sys import tempfile +import time import traceback from functools import partial from pathlib import Path @@ -75,6 +76,9 @@ def run_tests(app, cov, args, report_coverage, run_slow): traceback.print_exc() app.returncode = 1 finally: + print(f">>>>>>>>>> EXIT {app.returncode} <<<<<<<<<<") + # Add a short pause to make sure any log tailing gets a chance to flush + time.sleep(0.5) app.add_background_task(lambda app, **kwargs: app.exit()) @@ -149,12 +153,5 @@ def get_terminal_size(*args, **kwargs): ) app.add_background_task(lambda app, *kwargs: thread.start()) - # Add an on_exit handler that will terminate the test suite. - def exit_suite(app, **kwargs): - print(f">>>>>>>>>> EXIT {app.returncode} <<<<<<<<<<") - return True - - app.on_exit = exit_suite - # Start the test app. app.main_loop() From 8f92cd375ac7b7ccd18367a14e136382771fdffc Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 11 Aug 2023 15:34:53 +0800 Subject: [PATCH 06/66] Add docs, docstrings and type annotations for Command. --- core/src/toga/app.py | 4 +- core/src/toga/command.py | 300 ++++++-------------- core/src/toga/window.py | 2 +- docs/background/topics/commands.rst | 111 -------- docs/background/topics/index.rst | 1 - docs/reference/api/index.rst | 4 +- docs/reference/api/resources/command.rst | 135 +++++++++ docs/reference/api/resources/group.rst | 21 -- docs/reference/api/resources/index.rst | 1 - docs/reference/data/widgets_by_platform.csv | 3 +- 10 files changed, 235 insertions(+), 347 deletions(-) delete mode 100644 docs/background/topics/commands.rst delete mode 100644 docs/reference/api/resources/group.rst diff --git a/core/src/toga/app.py b/core/src/toga/app.py index e34c723ca4..dc4c250d66 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -144,7 +144,7 @@ def __init__( :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 resizeable: Can the window be manually resized by the user? + :param resizable: Can the window be manually resized by the user? :param minimizable: Can the window be minimized by the user? """ super().__init__( @@ -205,7 +205,7 @@ def __init__( :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 resizeable: Can the window be manually resized by the user? + :param resizable: Can the window be manually resized by the user? :param minimizable: Can the window be minimized by the user? """ self.doc = doc diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 4f66dabd83..cf3ecaf818 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -1,97 +1,84 @@ -import warnings +from __future__ import annotations + +from typing import TYPE_CHECKING from toga.handlers import wrapped_handler from toga.icons import Icon from toga.platform import get_platform_factory -# BACKWARDS COMPATIBILITY: a token object that can be used to differentiate -# between an explicitly provided ``None``, and an unspecified value falling -# back to a default. -NOT_PROVIDED = object() +if TYPE_CHECKING: + from toga.window import Window class Group: - """ - - Args: - text: - order: - parent: - """ - def __init__( self, - text=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label` is removed + text, order=None, section=None, parent=None, - label=None, # DEPRECATED! ): - ################################################################## - # 2022-07: Backwards compatibility - ################################################################## - # When deleting this block, also delete the NOT_PROVIDED - # placeholder, and replace its usage in default values. - - # label replaced with text - if label is not None: - if text is not NOT_PROVIDED: - raise ValueError( - "Cannot specify both `label` and `text`; " - "`label` has been deprecated, use `text`" - ) - else: - warnings.warn( - "Group.label has been renamed Group.text", DeprecationWarning - ) - text = label - elif text is NOT_PROVIDED: - # This would be raised by Python itself; however, we need to use a placeholder - # value as part of the migration from text->value. - raise TypeError( - "Group.__init__ missing 1 required positional argument: 'text'" - ) + """ + An collection of similar commands. + + Commands and sub-groups are sorted within sections inside a group. - ################################################################## - # End backwards compatibility. - ################################################################## + Groups can also be hierarchical; a group with no parent is a root group. + :param text: The name of the group + :param order: An integer that can be used to provide sorting order for commands. + Commands will be sorted according to order inside their section; if a + Command doesn't have an order, it will be sorted alphabetically by text + within its section. + :param section: An integer describing the section within the group where the + command should appear. If no section is specified, the command will be + allocated to section 0 within the group. + :param parent: The parent of this group; use ``None`` to describe a root group. + """ self.text = text self.order = order if order else 0 if parent is None and section is not None: raise ValueError("Section cannot be set without parent group") self.section = section if section else 0 - # First initialization needed for later + # Prime the underlying value of _parent so that the setter has a current value + # to work with self._parent = None self.parent = parent @property - def parent(self): + def parent(self) -> Group | None: + """The parent of this group; returns ``None`` if the group is a root group""" return self._parent @parent.setter - def parent(self, parent): + def parent(self, parent: Group | None): if parent is None: self._parent = None self._root = self return if parent == self or self.is_parent_of(parent): error_message = ( - "Cannot set {} to be a parent of {} " + f"Cannot set {parent.text} to be a parent of {self.text} " "because it causes a cyclic parenting." - ).format(parent.text, self.text) + ) raise ValueError(error_message) self._parent = parent self._root = parent.root @property - def root(self): + def root(self) -> Group: + """The root group for this group. + + This will be ``self`` if the group *is* a root group.""" return self._root - def is_parent_of(self, child): + def is_parent_of(self, child: Group | None) -> bool: + """Is this group a parent of the provided group? + + :param child: The potential child to check + :returns: True if this group is a parent of the provided child. + """ if child is None: return False if child.parent is None: @@ -101,6 +88,11 @@ def is_parent_of(self, child): return self.is_parent_of(child.parent) def is_child_of(self, parent): + """Is this group a child of the provided group? + + :param parent: The potential parent to check + :returns: True if this group is a child of the provided parent. + """ return parent.is_parent_of(self) def __hash__(self): @@ -124,7 +116,7 @@ def __repr__(self): ) @property - def key(self): + def key(self) -> tuple[(int, int, str)]: """A unique tuple describing the path to this group.""" self_tuple = (self.section, self.order, self.text) if self.parent is None: @@ -132,37 +124,12 @@ def key(self): return tuple([*self.parent.key, self_tuple]) @property - def path(self): + def path(self) -> list[Group]: """A list containing the chain of groups that contain this group.""" if self.parent is None: return [self] return [*self.parent.path, self] - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - # label replaced with text - @property - def label(self): - """Group text. - - **DEPRECATED: renamed as text** - - Returns: - The button text as a ``str`` - """ - warnings.warn("Group.label has been renamed Group.text", DeprecationWarning) - return self.text - - @label.setter - def label(self, label): - warnings.warn("Group.label has been renamed Group.text", DeprecationWarning) - self.text = label - - ###################################################################### - # End backwards compatibility. - ###################################################################### - Group.APP = Group("*", order=0) Group.FILE = Group("File", order=1) @@ -174,32 +141,10 @@ def label(self, label): class Command: - """ - Args: - action: a function to invoke when the command is activated. - text: caption for the command. - shortcut: (optional) a key combination that can be used to invoke the - command. - tooltip: (optional) a short description for what the command will do. - icon: (optional) a path to an icon resource to decorate the command. - group: (optional) a Group object describing a collection of similar - commands. If no group is specified, a default "Command" group will - be used. - section: (optional) an integer providing a sub-grouping. If no section - is specified, the command will be allocated to section 0 within the - group. - order: (optional) an integer indicating where a command falls within a - section. If a Command doesn't have an order, it will be sorted - alphabetically by text within its section. - enabled: whether to enable the command or not. - """ - def __init__( self, action, - text=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label` is removed + text, shortcut=None, tooltip=None, icon=None, @@ -207,47 +152,26 @@ def __init__( section=None, order=None, enabled=True, - factory=None, # DEPRECATED! - label=None, # DEPRECATED! ): - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - - ################################################################## - # 2022-07: Backwards compatibility - ################################################################## - # When deleting this block, also delete the NOT_PROVIDED - # placeholder, and replace its usage in default values. - - # label replaced with text - if label is not None: - if text is not NOT_PROVIDED: - raise ValueError( - "Cannot specify both `label` and `text`; " - "`label` has been deprecated, use `text`" - ) - else: - warnings.warn( - "Command.label has been renamed Command.text", DeprecationWarning - ) - text = label - elif text is NOT_PROVIDED: - # This would be raised by Python itself; however, we need to use a placeholder - # value as part of the migration from text->value. - raise TypeError( - "Command.__init__ missing 1 required positional argument: 'text'" - ) - - ################################################################## - # End backwards compatibility. - ################################################################## + """ + Create a new Command. + + :param action: A handler that will be invoked when the command is activated. + :param text: A text label for the command. + :param shortcut: A key combination that can be used to invoke the command. + :param tooltip: A short description for what the command will do. + :param icon: The icon, or icon resource, that can be used to decorate the + command if the platform requires. + :param group: The group of commands to which this command belongs. If no group + is specified, a default "Command" group will be used. + :param section: An integer describing the section within the group where the + command should appear. If no section is specified, the command will be + allocated to section 0 within the group. + :param order: An integer that can be used to provide sorting order for commands. + Commands will be sorted according to order inside their section; if a + Command doesn't have an order, it will be sorted alphabetically by text within its section. + :param enabled: Is the Command currently enabled? + """ self.text = text self.shortcut = shortcut @@ -267,18 +191,17 @@ def __init__( self.enabled = enabled and orig_action is not None @property - def key(self): - """A unique tuple describing the path to this command.""" - return tuple([*self.group.key, (self.section, self.order, self.text)]) + def key(self) -> tuple[(int, int, str)]: + """A unique tuple describing the path to this command. - def bind(self, factory=None): - warnings.warn( - "Commands no longer need to be explicitly bound.", DeprecationWarning - ) - return self._impl + Each element in the tuple describes the (section, order, text) for the + groups that must be navigated to invoke this action. + """ + return tuple([*self.group.key, (self.section, self.order, self.text)]) @property - def enabled(self): + def enabled(self) -> bool: + """Is the command currently enabled?""" return self._enabled @enabled.setter @@ -288,15 +211,16 @@ def enabled(self, value): self._impl.set_enabled(value) @property - def icon(self): - """The Icon for the app. + def icon(self) -> Icon | None: + """The Icon for the command. - :returns: A ``toga.Icon`` instance for the app's icon. + When specifying the icon, you can provide an icon instance, or a string resource + that can be resolved to an icon. """ return self._icon @icon.setter - def icon(self, icon_or_name): + def icon(self, icon_or_name: str | Icon): if isinstance(icon_or_name, Icon) or icon_or_name is None: self._icon = icon_or_name else: @@ -316,34 +240,12 @@ def __repr__(self): self.order, ) - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - # label replaced with text - @property - def label(self): - """Command text. - - **DEPRECATED: renamed as text** - - Returns: - The command text as a ``str`` - """ - warnings.warn("Command.label has been renamed Command.text", DeprecationWarning) - return self.text - - @label.setter - def label(self, label): - warnings.warn("Command.label has been renamed Command.text", DeprecationWarning) - self.text = label - - ###################################################################### - # End backwards compatibility. - ###################################################################### - class Break: def __init__(self, name): + """A representation of a separator between Command Groups, or between sections + in a Group. + """ self.name = name def __repr__(self): @@ -355,40 +257,26 @@ def __repr__(self): class CommandSet: - """ + def __init__(self, window: Window = None, on_change=None): + """ + A collection of commands. - Args: - factory: - widget: - on_change: + This is used as an internal representation of Menus, Toolbars, and any other + graphical manifestations of commands. - Todo: - * Add missing Docstrings. - """ + The collection can be iterated over to provide the display order of the commands + managed by the group. - def __init__( - self, - factory=None, # DEPRECATED! - widget=None, - on_change=None, - ): - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - - self.widget = widget + :param window: The window with which this CommandSet is associated. + :param on_change: A method that should be invoked when this command set changes. + """ + self.window = window self._commands = set() self.on_change = on_change def add(self, *commands): - if self.widget and self.widget.app is not None: - self.widget.app.commands.add(*commands) + if self.window and self.window.app is not None: + self.window.app.commands.add(*commands) self._commands.update(commands) if self.on_change: self.on_change() diff --git a/core/src/toga/window.py b/core/src/toga/window.py index bd1c6b6ecf..f74db98382 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -128,7 +128,7 @@ def __init__( size=size, ) - self._toolbar = CommandSet(widget=self, on_change=self._impl.create_toolbar) + self._toolbar = CommandSet(window=self, on_change=self._impl.create_toolbar) self.on_close = on_close diff --git a/docs/background/topics/commands.rst b/docs/background/topics/commands.rst deleted file mode 100644 index f2c79d95f0..0000000000 --- a/docs/background/topics/commands.rst +++ /dev/null @@ -1,111 +0,0 @@ -.. _commands: - -============================ -Commands, Menus and Toolbars -============================ - -A GUI requires more than just widgets laid out in a user interface - you'll -also want to allow the user to actually *do* something. In Toga, you do this -using ``Commands``. - -A command encapsulates a piece of functionality that the user can invoke - no -matter how they invoke it. It doesn't matter if they select a menu item, -press a button on a toolbar, or use a key combination - the functionality is -wrapped up in a Command. - -When a command is added to an application, Toga takes control of ensuring that -the command is exposed to the user in a way that they can access it. On desktop -platforms, this may result in a command being added to a menu. - -You can also choose to add a command (or commands) to a toolbar on a specific -window. - -Defining Commands -~~~~~~~~~~~~~~~~~ - -When you specify a ``Command``, you provide some additional metadata to help -classify and organize the commands in your application: - -* An **action** - a function to invoke when the command is activated. - -* A **label** - a name for the command to. - -* A **tooltip** - a short description of what the command will do - -* A **shortcut** - (optional) A key combination that can be used to invoke the command. - -* An **icon** - (optional) A path to an icon resource to decorate the command. - -* A **group** - (optional) a ``Group`` object describing a collection of similar commands. If no group is specified, a default "Command" group will be used. - -* A **section** - (optional) an integer providing a sub-grouping. If no section is specified, the command will be allocated to section 0 within the group. - -* An **order** - (optional) an integer indicating where a command falls within a section. If a ``Command`` doesn't have an order, it will be sorted alphabetically by label within its section. - -Commands may not use all the metadata - for example, on some platforms, menus -will contain icons; on other platforms they won't. Toga will use the metadata -if it is provided, but ignore it (or substitute an appropriate default) if it -isn't. - -Commands can be enabled and disabled; if you disable a command, it will -automatically disable any toolbar or menu item where the command appears. - -Groups -~~~~~~ - -Toga provides a number of ready-to-use groups: - -* ``Group.APP`` - Application level control -* ``Group.FILE`` - File commands -* ``Group.EDIT`` - Editing commands -* ``Group.VIEW`` - Commands to alter the appearance of content -* ``Group.COMMANDS`` - A Default -* ``Group.WINDOW`` - Commands for managing different windows in the app -* ``Group.HELP`` - Help content - -You can also define custom groups. - -Example -~~~~~~~ - -The following is an example of using menus and commands:: - - import toga - - def callback(sender): - print("Command activated") - - def build(app): - ... - stuff_group = Group('Stuff', order=40) - - cmd1 = toga.Command( - callback, - label='Example command', - tooltip='Tells you when it has been activated', - shortcut='k', - icon='icons/pretty.png', - group=stuff_group, - section=0 - ) - cmd2 = toga.Command( - ... - ) - ... - - app.commands.add(cmd1, cmd4, cmd3) - app.main_window.toolbar.add(cmd2, cmd3) - -This code defines a command ``cmd1`` that will be placed in the first section of -the "Stuff" group. It can be activated by pressing CTRL-k (or CMD-K on a Mac). - -The definitions for ``cmd2``, ``cmd3``, and ``cmd4`` have been omitted, but would -follow a similar pattern. - -It doesn't matter what order you add commands to the app - the group, section -and order will be used to put the commands in the right order. - -If a command is added to a toolbar, it will automatically be added to the app -as well. It isn't possible to have functionality exposed on a toolbar that -isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though -it wasn't explicitly added to the app commands. diff --git a/docs/background/topics/index.rst b/docs/background/topics/index.rst index 31c750910b..389681b01a 100644 --- a/docs/background/topics/index.rst +++ b/docs/background/topics/index.rst @@ -6,5 +6,4 @@ Topic guides :maxdepth: 1 layout - commands data-sources diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index ce1015e988..cd570f55a6 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -76,9 +76,9 @@ Resources ==================================================================== ======================================================================== :doc:`App Paths ` A mechanism for obtaining platform-appropriate file system locations for an application. - :doc:`Command ` Command + :doc:`Command ` A representation of app functionality that the user can invoke from + menus or toolbars. :doc:`Font ` Fonts - :doc:`Group ` Command group :doc:`Icon ` An icon for buttons, menus, etc :doc:`Image ` An image :doc:`ListSource ` A data source describing an ordered list of data. diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index d3a84428ac..a6b2d15a99 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -1,6 +1,8 @@ Command ======= +A representation of app functionality that the user can invoke from menus or toolbars. + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -12,9 +14,142 @@ Command Usage ----- +A GUI requires more than just widgets laid out in a user interface - you'll also want to +allow the user to actually *do* something. In Toga, you do this using +:class:`~toga.Command`. + +A command encapsulates a piece of functionality that the user can invoke - no matter how +they invoke it. It doesn't matter if they select a menu item, press a button on a +toolbar, or use a key combination - the functionality is wrapped up in a Command. + +When a command is added to an application, Toga takes control of ensuring that the +command is exposed to the user in a way that they can access it. On desktop platforms, +this may result in a command being added to a menu. + +Commands can then be organized into a :class:`~toga.Group` of similar commands; inside a +group, commands can be organized into sections. Groups are also hierarchical, so a group +can contain a sub-group, which can contain a sub-group, and so on. + +A collection of groups and commands is called a :class:`~toga.CommandSet`. The menus of +an app and the toolbar on a window are both examples of CommandSets. + +Defining Commands +~~~~~~~~~~~~~~~~~ + +When you specify a :class:`~toga.Command`, you provide some additional metadata to help +classify and organize the commands in your application: + +* **action**: A handler to invoke when the command is activated. + +* **text**: A short label for the command. + +* **tooltip**: A short description of what the command will do + +* **shortcut**: (optional) A key combination that can be used to invoke the command. + +* **icon**: (optional) A path to an icon resource to decorate the command. + +* **group**: (optional) A :class:`~toga.Group` object describing a collection of similar commands. + If no group is specified, a default "Command" group will be used. + +* **section**: (optional) An integer providing a sub-grouping. If no section is + specified, the command will be allocated to section 0 within the group. + +* **order**: (optional) An integer indicating where a command falls within a section. + If a :class:`~toga.Command` doesn't have an order, it will be sorted alphabetically by label + within its section. + +Commands may not use all the metadata - for example, on some platforms, menus +will contain icons; on other platforms they won't. Toga will use the metadata +if it is provided, but ignore it (or substitute an appropriate default) if it +isn't. + +Commands can be enabled and disabled; if you disable a command, it will +automatically disable any toolbar or menu item where the command appears. + +Defining Groups +~~~~~~~~~~~~~~~ + +When you specify a :class:`~toga.Group`, you provide some additional metadata to help +classify and organize the commands in your application: + +* **text**: A short label for the group. + +* **section**: (optional) An integer providing a sub-grouping. If no section is + specified, the command will be allocated to section 0 within the group. + +* **order**: (optional) An integer indicating where a command falls within a section. + If a :class:`~toga.Command` doesn't have an order, it will be sorted alphabetically by label + within its section. + +* **parent**: (optional) The parent :class:`~toga.Group` of this group (if any). + +Toga provides a number of ready-to-use groups: + +* ``Group.APP`` - Application level control +* ``Group.FILE`` - File commands +* ``Group.EDIT`` - Editing commands +* ``Group.VIEW`` - Commands to alter the appearance of content +* ``Group.COMMANDS`` - A default group for user-provided commands +* ``Group.WINDOW`` - Commands for managing windows in the app +* ``Group.HELP`` - Help content + +Example +~~~~~~~ + +The following is an example of using menus and commands: + +.. code-block:: python + + import toga + + def callback(sender, **kwargs): + print("Command activated") + + stuff_group = Group('Stuff', order=40) + + cmd1 = toga.Command( + callback, + label='Example command', + tooltip='Tells you when it has been activated', + shortcut='k', + icon='icons/pretty.png', + group=stuff_group, + section=0 + ) + cmd2 = toga.Command( + ... + ) + ... + + app.commands.add(cmd1, cmd4, cmd3) + app.main_window.toolbar.add(cmd2, cmd3) + +This code defines a command ``cmd1`` that will be placed in the first section of the +"Stuff" group. It can be activated by pressing CTRL-k (or CMD-K on a Mac). + +The definitions for ``cmd2``, ``cmd3``, and ``cmd4`` have been omitted, but would follow +a similar pattern. + +It doesn't matter what order you add commands to the app - the group, section and order +will be used to put the commands in the right order. + +If a command is added to a toolbar, it will automatically be added to the app +as well. It isn't possible to have functionality exposed on a toolbar that +isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though +it wasn't explicitly added to the app commands. + Reference --------- .. autoclass:: toga.Command :members: :undoc-members: + +.. autoclass:: toga.Group + :members: + :undoc-members: + +.. autoclass:: toga.CommandSet + :members: + :undoc-members: diff --git a/docs/reference/api/resources/group.rst b/docs/reference/api/resources/group.rst deleted file mode 100644 index 15293f64d8..0000000000 --- a/docs/reference/api/resources/group.rst +++ /dev/null @@ -1,21 +0,0 @@ -Group -===== - -.. 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 - :exclude: {0: '(?!(Group|Component))'} - - - -Usage ------ - -Reference ---------- - -.. autoclass:: toga.Group - :members: - :undoc-members: diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index 7f2d6e94d1..0544de2680 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -6,7 +6,6 @@ Resources app_paths fonts command - group icons images sources/source diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 8b87ae91d3..fb520ac198 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -30,7 +30,6 @@ SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divi OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content.,|b|,|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|, Font,Resource,:class:`~toga.Font`,Fonts,|b|,|b|,|b|,|b|,|b|, -Command,Resource,:class:`~toga.Command`,Command,|b|,|b|,|b|,,|b|, -Group,Resource,:class:`~toga.Group`,Command group,|b|,|b|,|b|,|b|,|b|, +Command,Resource,:class:`~toga.Command`,A representation of app functionality that the user can invoke from menus or toolbars.,|b|,|b|,|b|,,|b|, Icon,Resource,:class:`~toga.Icon`,"An icon for buttons, menus, etc",|b|,|b|,|b|,|b|,|b|, Image,Resource,:class:`~toga.Image`,An image,|y|,|y|,|y|,|y|,|y|, From 2076dd9888c9edde52da88b5ecbc93b46a0c8f90 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 12 Aug 2023 11:34:19 +0800 Subject: [PATCH 07/66] Complete core test coverage for Command. --- core/src/toga/command.py | 177 +++++++----- core/src/toga/window.py | 3 +- core/tests/command/conftest.py | 28 ++ core/tests/command/constants.py | 38 --- core/tests/command/test_command.py | 328 +++++++++++++--------- core/tests/command/test_commands_group.py | 183 ------------ core/tests/command/test_commands_set.py | 51 ---- core/tests/command/test_commandset.py | 130 +++++++++ core/tests/command/test_group.py | 290 +++++++++++++++++++ core/tests/test_deprecated_factory.py | 12 - core/tests/utils.py | 22 -- docs/reference/api/resources/command.rst | 6 +- 12 files changed, 753 insertions(+), 515 deletions(-) create mode 100644 core/tests/command/conftest.py delete mode 100644 core/tests/command/constants.py delete mode 100644 core/tests/command/test_commands_group.py delete mode 100644 core/tests/command/test_commands_set.py create mode 100644 core/tests/command/test_commandset.py create mode 100644 core/tests/command/test_group.py delete mode 100644 core/tests/utils.py diff --git a/core/src/toga/command.py b/core/src/toga/command.py index cf3ecaf818..45bcc0096d 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -1,22 +1,22 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Protocol from toga.handlers import wrapped_handler from toga.icons import Icon from toga.platform import get_platform_factory if TYPE_CHECKING: - from toga.window import Window + from toga.app import App class Group: def __init__( self, - text, - order=None, - section=None, - parent=None, + text: str, + order: int | None = None, + section: int | None = None, + parent: Group | None = None, ): """ An collection of similar commands. @@ -30,9 +30,10 @@ def __init__( Commands will be sorted according to order inside their section; if a Command doesn't have an order, it will be sorted alphabetically by text within its section. - :param section: An integer describing the section within the group where the - command should appear. If no section is specified, the command will be - allocated to section 0 within the group. + :param section: An integer describing the section within the parent group where + the command should appear. If no section is specified, the command will be + allocated to section 0 within the group. A section cannot be specified + unless a parent is also specified. :param parent: The parent of this group; use ``None`` to describe a root group. """ self.text = text @@ -55,23 +56,23 @@ def parent(self) -> Group | None: def parent(self, parent: Group | None): if parent is None: self._parent = None - self._root = self - return - if parent == self or self.is_parent_of(parent): - error_message = ( - f"Cannot set {parent.text} to be a parent of {self.text} " - "because it causes a cyclic parenting." + elif parent == self: + raise ValueError("A group cannot be it's own parent") + elif self.is_parent_of(parent): + raise ValueError( + f"Cannot set parent; {self.text!r} is an ancestor of {parent.text!r}." ) - raise ValueError(error_message) - self._parent = parent - self._root = parent.root + else: + self._parent = parent @property def root(self) -> Group: """The root group for this group. This will be ``self`` if the group *is* a root group.""" - return self._root + if self.parent is None: + return self + return self.parent.root def is_parent_of(self, child: Group | None) -> bool: """Is this group a parent of the provided group? @@ -87,33 +88,41 @@ def is_parent_of(self, child: Group | None) -> bool: return True return self.is_parent_of(child.parent) - def is_child_of(self, parent): + def is_child_of(self, parent: Group | None) -> bool: """Is this group a child of the provided group? :param parent: The potential parent to check :returns: True if this group is a child of the provided parent. """ + if parent is None: + return False return parent.is_parent_of(self) - def __hash__(self): + def __hash__(self) -> int: return hash(self.key) - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: + if not isinstance(other, [Group, Command]): + return False return self.key < other.key - def __gt__(self, other): + def __gt__(self, other: Any) -> bool: + if not isinstance(other, [Group, Command]): + return False return other < self - def __eq__(self, other): - if other is None: + def __eq__(self, other: Any) -> bool: + if not isinstance(other, [Group, Command]): return False return self.key == other.key - def __repr__(self): - parent_string = "None" if self.parent is None else self.parent.text - return "".format( - self.text, self.order, parent_string + def __repr__(self) -> str: + parent_string = ( + f" parent={self.parent} section={self.section}" + if self.parent is not None + else "" ) + return f"" @property def key(self) -> tuple[(int, int, str)]: @@ -123,13 +132,6 @@ def key(self) -> tuple[(int, int, str)]: return tuple([self_tuple]) return tuple([*self.parent.key, self_tuple]) - @property - def path(self) -> list[Group]: - """A list containing the chain of groups that contain this group.""" - if self.parent is None: - return [self] - return [*self.parent.path, self] - Group.APP = Group("*", order=0) Group.FILE = Group("File", order=1) @@ -140,18 +142,31 @@ def path(self) -> list[Group]: Group.HELP = Group("Help", order=100) +class ActionHandler(Protocol): + def __call__(self, command: Command, **kwargs) -> bool: + """A handler that will be invoked when a Command is invoked. + + .. note:: + ``**kwargs`` ensures compatibility with additional arguments + introduced in future versions. + + :param command: The command that triggered the action. + """ + ... + + class Command: def __init__( self, - action, - text, - shortcut=None, - tooltip=None, - icon=None, - group=None, - section=None, - order=None, - enabled=True, + action: ActionHandler | None, + text: str, + shortcut: str | None = None, + tooltip: str | None = None, + icon: str | Icon | None = None, + group: Group | None = None, + section: int | None = None, + order: int | None = None, + enabled: bool = True, ): """ Create a new Command. @@ -205,10 +220,9 @@ def enabled(self) -> bool: return self._enabled @enabled.setter - def enabled(self, value): + def enabled(self, value: bool): self._enabled = value - if self._impl is not None: - self._impl.set_enabled(value) + self._impl.set_enabled(value) @property def icon(self) -> Icon | None: @@ -226,29 +240,35 @@ def icon(self, icon_or_name: str | Icon): else: self._icon = Icon(icon_or_name) - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: + if not isinstance(other, [Group, Command]): + return False return self.key < other.key - def __gt__(self, other): + def __gt__(self, other: Any) -> bool: + if not isinstance(other, [Group, Command]): + return False return other < self - def __repr__(self): - return "".format( - self.text, - self.group, - self.section, - self.order, + def __repr__(self) -> bool: + return ( + f"" ) class Break: - def __init__(self, name): + def __init__(self, name: str): """A representation of a separator between Command Groups, or between sections in a Group. + + :param name: A name of the break type. """ self.name = name - def __repr__(self): + def __repr__(self) -> str: return f"<{self.name} break>" @@ -256,35 +276,58 @@ def __repr__(self): SECTION_BREAK = Break("Section") +class CommandSetChangeHandler(Protocol): + def __call__(self) -> None: + """A handler that will be invoked when a Command or Group is added to the CommandSet. + + .. note:: + ``**kwargs`` ensures compatibility with additional arguments + introduced in future versions. + + :return: Nothing + """ + ... + + class CommandSet: - def __init__(self, window: Window = None, on_change=None): + def __init__(self, on_change: CommandSetChangeHandler = None): """ A collection of commands. This is used as an internal representation of Menus, Toolbars, and any other - graphical manifestations of commands. + graphical manifestations of commands. You generally don't need to construct a + CommandSet of your own; you should use existing app or window level CommandSet + instances. The collection can be iterated over to provide the display order of the commands managed by the group. - :param window: The window with which this CommandSet is associated. :param on_change: A method that should be invoked when this command set changes. """ - self.window = window + self._app = None self._commands = set() self.on_change = on_change - def add(self, *commands): - if self.window and self.window.app is not None: - self.window.app.commands.add(*commands) + def add(self, *commands: Command | Group): + if self.app and self.app is not None: + self.app.commands.add(*commands) self._commands.update(commands) if self.on_change: self.on_change() - def __len__(self): + @property + def app(self) -> App: + return self._app + + @app.setter + def app(self, value: App): + self._app = value + self._app.commands.add(*self._commands) + + def __len__(self) -> int: return len(self._commands) - def __iter__(self): + def __iter__(self) -> Command | Group | Break: prev_cmd = None for cmd in sorted(self._commands): if prev_cmd: diff --git a/core/src/toga/window.py b/core/src/toga/window.py index f74db98382..6bf469004c 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -128,7 +128,7 @@ def __init__( size=size, ) - self._toolbar = CommandSet(window=self, on_change=self._impl.create_toolbar) + self._toolbar = CommandSet(on_change=self._impl.create_toolbar) self.on_close = on_close @@ -152,6 +152,7 @@ def app(self, app: App) -> None: self._app = app self._impl.set_app(app._impl) + self.toolbar.app = app if self.content: self.content.app = app diff --git a/core/tests/command/conftest.py b/core/tests/command/conftest.py new file mode 100644 index 0000000000..804ecbe30d --- /dev/null +++ b/core/tests/command/conftest.py @@ -0,0 +1,28 @@ +import pytest + +import toga + + +@pytest.fixture +def parent_group_1(): + return toga.Group("P", 1) + + +@pytest.fixture +def child_group_1(parent_group_1): + return toga.Group("C", order=2, parent=parent_group_1) + + +@pytest.fixture +def child_group_2(parent_group_1): + return toga.Group("B", order=4, parent=parent_group_1) + + +@pytest.fixture +def parent_group_2(): + return toga.Group("O", 2) + + +@pytest.fixture +def child_group_3(parent_group_2): + return toga.Group("A", 2, parent=parent_group_2) diff --git a/core/tests/command/constants.py b/core/tests/command/constants.py deleted file mode 100644 index e4cf81958b..0000000000 --- a/core/tests/command/constants.py +++ /dev/null @@ -1,38 +0,0 @@ -import toga - -PARENT_GROUP1 = toga.Group("P", 1) -CHILD_GROUP1 = toga.Group("C", order=2, parent=PARENT_GROUP1) -CHILD_GROUP2 = toga.Group("B", order=4, parent=PARENT_GROUP1) -PARENT_GROUP2 = toga.Group("O", 2) -CHILD_GROUP3 = toga.Group("A", 2, parent=PARENT_GROUP2) - -A = toga.Command(None, "A", group=PARENT_GROUP2, order=1) -S = toga.Command(None, "S", group=PARENT_GROUP1, order=5) -T = toga.Command(None, "T", group=CHILD_GROUP2, order=2) -U = toga.Command(None, "U", group=CHILD_GROUP2, order=1) -V = toga.Command(None, "V", group=PARENT_GROUP1, order=3) -B = toga.Command(None, "B", group=CHILD_GROUP1, section=2, order=1) -W = toga.Command(None, "W", group=CHILD_GROUP1, order=4) -X = toga.Command(None, "X", group=CHILD_GROUP1, order=2) -Y = toga.Command(None, "Y", group=CHILD_GROUP1, order=1) -Z = toga.Command(None, "Z", group=PARENT_GROUP1, order=1) - -COMMANDS_IN_ORDER = [Z, Y, X, W, B, V, U, T, S, A] -COMMANDS_IN_SET = [ - Z, - toga.GROUP_BREAK, - Y, - X, - W, - toga.SECTION_BREAK, - B, - toga.GROUP_BREAK, - V, - toga.GROUP_BREAK, - U, - T, - toga.GROUP_BREAK, - S, - toga.GROUP_BREAK, - A, -] diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index 797d65be24..64a83c9162 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -1,145 +1,193 @@ -from tests.command.constants import COMMANDS_IN_ORDER, PARENT_GROUP1 -from tests.utils import order_test +from unittest.mock import Mock + +import pytest import toga -from toga_dummy.utils import TestCase - - -class TestCommand(TestCase): - def setUp(self): - super().setUp() - # We need to define a test app to instantiate paths. - self.app = toga.App( - formal_name="Test App", - app_id="org.beeware.test-app", - ) - - def test_command_init_defaults(self): - cmd = toga.Command(lambda x: print("Hello World"), "test") - self.assertEqual(cmd.text, "test") - self.assertEqual(cmd.shortcut, None) - self.assertEqual(cmd.tooltip, None) - self.assertEqual(cmd.icon, None) - self.assertEqual(cmd.group, toga.Group.COMMANDS) - self.assertEqual(cmd.section, 0) - self.assertEqual(cmd.order, 0) - self.assertTrue(cmd._enabled) - - def test_command_init_kargs(self): - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - self.assertEqual(cmd.text, "test") - self.assertEqual(cmd.shortcut, "t") - self.assertEqual(cmd.tooltip, "test command") - self.assertEqual(cmd.icon.path, "icons/none.png") - self.assertEqual(cmd.group, grp) - self.assertEqual(cmd.section, 1) - self.assertEqual(cmd.order, 1) - self.assertTrue(cmd._enabled) - self.assertTrue(cmd.enabled) - cmd.enabled = False - self.assertFalse(cmd._enabled) - self.assertFalse(cmd.enabled) - - def test_command_bind(self): - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - - with self.assertWarns(DeprecationWarning): - return_val = cmd.bind() - self.assertEqual(return_val, cmd._impl) - - def test_command_enabler(self): - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - - cmd.enabled = False - self.assertActionPerformedWith(cmd, "set enabled", value=False) - cmd.enabled = True - self.assertActionPerformedWith(cmd, "set enabled", value=True) - - def test_command_repr(self): - self.assertEqual( - repr(toga.Command(None, "A", group=PARENT_GROUP1, order=1, section=4)), - " section=4 order=1>", - ) - - test_order_commands_by_text = order_test( - toga.Command(None, "A"), toga.Command(None, "B") +from toga.command import Break +from toga_dummy.utils import assert_action_performed_with + + +def assert_order(*items): + for i in range(0, len(items) - 1): + for j in range(i + 1, len(items)): + assert items[i] < items[j] + assert items[j] > items[i] + + +@pytest.fixture +def app(): + return toga.App("Command Test", "org.beeware.command") + + +def test_break(): + """A break can be created""" + + example_break = Break("Example") + assert repr(example_break) == "" + + +def test_create(): + """A command can be created with defaults""" + cmd = toga.Command(None, "Test command") + + assert cmd.text == "Test command" + assert cmd.shortcut is None + assert cmd.tooltip is None + assert cmd.group == toga.Group.COMMANDS + assert cmd.section == 0 + assert cmd.order == 0 + assert cmd.action._raw is None + + assert ( + repr(cmd) + == " section=0 order=0>" ) - test_order_commands_by_number = order_test( - toga.Command(None, "B", order=1), toga.Command(None, "A", order=2) + + +def test_create_explicit(app): + """A command can be created with explicit arguments""" + grp = toga.Group("Test group", order=10) + + handler = Mock() + cmd = toga.Command( + handler, + text="Test command", + tooltip="This is a test command", + shortcut="t", + group=grp, + section=3, + order=4, + ) + + assert cmd.text == "Test command" + assert cmd.shortcut == "t" + assert cmd.tooltip == "This is a test command" + assert cmd.group == grp + assert cmd.section == 3 + assert cmd.order == 4 + + assert cmd.action._raw == handler + + assert ( + repr(cmd) + == " section=3 order=4>" ) - test_order_commands_by_section = order_test( - toga.Command(None, "B", group=PARENT_GROUP1, section=1, order=2), - toga.Command(None, "A", group=PARENT_GROUP1, section=2, order=1), + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon_construction(app, construct): + """The command icon can be set during construction""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + cmd = toga.Command(None, "Test command", icon=icon) + + # Default icon matches app name + assert isinstance(cmd.icon, toga.Icon) + assert cmd.icon.path == "path/to/icon" + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon(app, construct): + """The command icon can be changed""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + cmd = toga.Command(None, "Test command") + + # No icon by default + assert cmd.icon is None + + # Change icon + cmd.icon = icon + + # Icon path matches + assert isinstance(cmd.icon, toga.Icon) + assert cmd.icon.path == "path/to/icon" + + +@pytest.mark.parametrize( + "action, enabled, initial_state", + [ + (Mock(), True, True), + (Mock(), False, False), + (None, True, False), + (None, False, False), + ], +) +def test_enable(action, enabled, initial_state): + cmd = toga.Command(action, text="Test command", enabled=enabled) + + assert cmd.enabled is initial_state + + # Set enabled; triggers an implementation response + cmd.enabled = True + assert_action_performed_with(cmd, "set enabled", value=True) + + # Disable; triggers an implementation response + cmd.enabled = False + assert_action_performed_with(cmd, "set enabled", value=False) + + # Disable again; triggers an implementation response + cmd.enabled = False + assert_action_performed_with(cmd, "set enabled", value=False) + + # Set enabled; triggers an implementation response + cmd.enabled = True + assert_action_performed_with(cmd, "set enabled", value=True) + + +def test_order_by_text(): + """Commands are ordered by text when group, section and order match""" + assert_order( + toga.Command(None, "A"), + toga.Command(None, "B"), + ) + + +def test_order_by_number(): + """Commands are ordered by number when group and section match""" + assert_order( + toga.Command(None, "B", order=1), + toga.Command(None, "A", order=2), + ) + + +def test_order_by_section(parent_group_1): + """Section ordering takes priority over order and text""" + assert_order( + toga.Command(None, "B", group=parent_group_1, section=1, order=2), + toga.Command(None, "A", group=parent_group_1, section=2, order=1), + ) + + +def test_order_by_groups(parent_group_1, parent_group_2, child_group_1, child_group_2): + """Commands are ordered by group over""" + + command_z = toga.Command(None, "Z", group=parent_group_1, order=1) + command_y = toga.Command(None, "Y", group=child_group_1, order=1) + command_x = toga.Command(None, "X", group=child_group_1, order=2) + command_w = toga.Command(None, "W", group=child_group_1, order=4) + command_b = toga.Command(None, "B", group=child_group_1, section=2, order=1) + command_v = toga.Command(None, "V", group=parent_group_1, order=3) + command_u = toga.Command(None, "U", group=child_group_2, order=1) + command_t = toga.Command(None, "T", group=child_group_2, order=2) + command_s = toga.Command(None, "S", group=parent_group_1, order=5) + command_a = toga.Command(None, "A", group=parent_group_2, order=1) + + assert_order( + command_z, + command_y, + command_x, + command_w, + command_b, + command_v, + command_u, + command_t, + command_s, + command_a, ) - test_order_commands_by_groups = order_test(*COMMANDS_IN_ORDER) - - def test_missing_argument(self): - "If the no text is provided for the group, an error is raised" - # This test is only required as part of the backwards compatibility - # path renaming label->text; when that shim is removed, this teset - # validates default Python behavior - with self.assertRaises(TypeError): - toga.Command(lambda x: print("Hello World")) - - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - - def test_label_deprecated(self): - cmd = toga.Command(lambda x: print("Hello World"), label="test") - new_text = "New Text" - with self.assertWarns(DeprecationWarning): - cmd.label = new_text - with self.assertWarns(DeprecationWarning): - self.assertEqual(cmd.label, new_text) - self.assertEqual(cmd.text, new_text) - - def test_init_with_deprecated(self): - # label is a deprecated argument - with self.assertWarns(DeprecationWarning): - toga.Command( - lambda x: print("Hello World"), - label="test", - ) - - # can't specify both label *and* text - with self.assertRaises(ValueError): - toga.Command( - lambda x: print("Hello World"), - label="test", - text="test", - ) - - ###################################################################### - # End backwards compatibility. - ###################################################################### diff --git a/core/tests/command/test_commands_group.py b/core/tests/command/test_commands_group.py deleted file mode 100644 index 2ab00fe2d8..0000000000 --- a/core/tests/command/test_commands_group.py +++ /dev/null @@ -1,183 +0,0 @@ -import unittest - -from tests.command.constants import PARENT_GROUP1, PARENT_GROUP2 -from tests.utils import order_test - -import toga - - -class TestCommandsGroup(unittest.TestCase): - def test_group_init_no_order(self): - grp = toga.Group("text") - self.assertEqual(grp.text, "text") - self.assertEqual(grp.order, 0) - - def test_group_init_with_order(self): - grp = toga.Group("text", 2) - self.assertEqual(grp.text, "text") - self.assertEqual(grp.order, 2) - - def test_hashable(self): - grp1 = toga.Group("text 1") - grp2 = toga.Group("text 2") - - # The hash is based on the full path, not just the text. - # This allows texts to be non-unique, as long as they're in - # different groups - grp1_child = toga.Group("text", parent=grp1) - grp2_child = toga.Group("text", parent=grp2) - - # Insert the groups as keys in a dict. This is - # only possible if Group is hashable. - groups = { - grp1: "First", - grp2: "Second", - grp1_child: "Child of 1", - grp2_child: "Child of 2", - } - - self.assertEqual(groups[grp1], "First") - self.assertEqual(groups[grp2], "Second") - self.assertEqual(groups[grp1_child], "Child of 1") - self.assertEqual(groups[grp2_child], "Child of 2") - - def test_group_eq(self): - self.assertEqual(toga.Group("A"), toga.Group("A")) - self.assertEqual(toga.Group("A", 1), toga.Group("A", 1)) - self.assertNotEqual(toga.Group("A"), toga.Group("B")) - self.assertNotEqual(toga.Group("A"), None) - self.assertNotEqual(toga.Group("A", 1), toga.Group("A", 2)) - self.assertNotEqual(toga.Group("A", 1), toga.Group("B", 1)) - - def test_set_parent_in_constructor(self): - parent = toga.Group("parent") - child = toga.Group("child", parent=parent) - self.assert_parent_and_root(parent, None, parent) - self.assert_parent_and_root(child, parent, parent) - - def test_set_parent_in_property(self): - parent = toga.Group("parent") - child = toga.Group("child") - child.parent = parent - self.assert_parent_and_root(parent, None, parent) - self.assert_parent_and_root(child, parent, parent) - - def test_change_parent(self): - parent1 = toga.Group("parent1") - parent2 = toga.Group("parent2") - child = toga.Group("child", parent=parent1) - child.parent = parent2 - self.assert_parent_and_root(parent1, None, parent1) - self.assert_parent_and_root(parent2, None, parent2) - self.assert_parent_and_root(child, parent2, parent2) - - def test_is_parent_and_is_child_of(self): - top = toga.Group("C") - middle = toga.Group("B", parent=top) - bottom = toga.Group("A", parent=middle) - groups = [top, middle, bottom] - for i in range(0, 2): - for j in range(i + 1, 3): - self.assertTrue(groups[i].is_parent_of(groups[j])) - self.assertTrue(groups[j].is_child_of(groups[i])) - - def test_is_parent_of_none(self): - group = toga.Group("A") - self.assertFalse(group.is_parent_of(None)) - - def test_root(self): - top = toga.Group("C") - middle = toga.Group("B", parent=top) - bottom = toga.Group("A", parent=middle) - self.assertEqual(top.root, top) - self.assertEqual(middle.root, top) - self.assertEqual(top.root, top) - self.assertEqual(bottom.root, top) - - test_order_by_number = order_test(toga.Group("A", 1), toga.Group("A", 2)) - test_order_ignore_text = order_test(toga.Group("B", 1), toga.Group("A", 2)) - test_order_by_text = order_test(toga.Group("A"), toga.Group("B")) - test_order_by_groups = order_test( - PARENT_GROUP1, - toga.Group("C", parent=PARENT_GROUP1), - toga.Group("D", parent=PARENT_GROUP1), - toga.Group("A", parent=PARENT_GROUP1, section=2), - PARENT_GROUP2, - toga.Group("B", parent=PARENT_GROUP2), - ) - - def test_group_repr(self): - parent = toga.Group("P") - self.assertEqual(repr(toga.Group("A")), "") - self.assertEqual( - repr(toga.Group("A", parent=parent)), "" - ) - - def test_set_section_without_parent(self): - with self.assertRaises(ValueError): - toga.Group("A", section=2) - - def test_set_parent_causes_cyclic_parenting(self): - parent = toga.Group("P") - child = toga.Group("C", parent=parent) - with self.assertRaises(ValueError): - parent.parent = child - self.assert_parent_and_root(parent, None, parent) - self.assert_parent_and_root(child, parent, parent) - - def test_cannot_set_self_as_parent(self): - group = toga.Group("P") - with self.assertRaises(ValueError): - group.parent = group - self.assert_parent_and_root(group, None, group) - - def test_cannot_set_child_to_be_a_parent_of_its_grandparent(self): - grandparent = toga.Group("G") - parent = toga.Group("P", parent=grandparent) - child = toga.Group("C", parent=parent) - with self.assertRaises(ValueError): - grandparent.parent = child - self.assert_parent_and_root(grandparent, None, grandparent) - self.assert_parent_and_root(parent, grandparent, grandparent) - self.assert_parent_and_root(child, parent, grandparent) - - def assert_parent_and_root(self, group, parent, root): - self.assertEqual(group.parent, parent) - self.assertEqual(group.root, root) - - def test_missing_argument(self): - "If the no text is provided for the group, an error is raised" - # This test is only required as part of the backwards compatibility - # path renaming label->text; when that shim is removed, this teset - # validates default Python behavior - with self.assertRaises(TypeError): - toga.Group() - - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - - def test_label_deprecated(self): - grp = toga.Group(label="text") - new_text = "New Text" - with self.assertWarns(DeprecationWarning): - grp.label = new_text - with self.assertWarns(DeprecationWarning): - self.assertEqual(grp.label, new_text) - self.assertEqual(grp.text, new_text) - - def test_init_with_deprecated(self): - # label is a deprecated argument - with self.assertWarns(DeprecationWarning): - toga.Group(label="test") - - # can't specify both label *and* text - with self.assertRaises(ValueError): - toga.Group( - label="test", - text="test", - ) - - ###################################################################### - # End backwards compatibility. - ###################################################################### diff --git a/core/tests/command/test_commands_set.py b/core/tests/command/test_commands_set.py deleted file mode 100644 index 75139541b5..0000000000 --- a/core/tests/command/test_commands_set.py +++ /dev/null @@ -1,51 +0,0 @@ -import random -import unittest -from unittest.mock import Mock - -from tests.command.constants import COMMANDS_IN_ORDER, COMMANDS_IN_SET - -import toga - - -class TestCommandSet(unittest.TestCase): - changed = False - - def _changed(self): - self.changed = True - - def test_cmdset_init(self): - test_widget = toga.Widget() - cs = toga.CommandSet(test_widget) - self.assertEqual(cs._commands, set()) - self.assertEqual(cs.on_change, None) - - def test_cmdset_add(self): - self.changed = False - test_widget = toga.Widget() - cs = toga.CommandSet(widget=test_widget, on_change=self._changed) - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - cs.add(cmd) - - self.assertTrue(self.changed) - self.assertIsNotNone(cmd._impl) - - def test_cmdset_iter_in_order(self): - test_widget = toga.Widget() - test_widget._impl = Mock() - test_widget.app = Mock() - cs = toga.CommandSet(widget=test_widget) - commands = list(COMMANDS_IN_ORDER) - random.shuffle(commands) - cs.add(*commands) - test_widget.app.commands.add.assert_called_once_with(*commands) - self.assertEqual(list(cs), COMMANDS_IN_SET) diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py new file mode 100644 index 0000000000..8725f91907 --- /dev/null +++ b/core/tests/command/test_commandset.py @@ -0,0 +1,130 @@ +import random +from unittest.mock import Mock + +import pytest + +import toga +from toga.command import GROUP_BREAK, SECTION_BREAK, CommandSet + + +@pytest.fixture +def app(): + return toga.App("CommandSet Test", "org.beeware.commandset") + + +def test_create(): + """A CommandSet can be created with defaults""" + cs = CommandSet() + + assert list(cs) == [] + assert cs.on_change is None + + +def test_create_with_values(): + """A CommandSet can be created with values""" + change_handler = Mock() + cs = CommandSet(on_change=change_handler) + + assert list(cs) == [] + assert cs.on_change == change_handler + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_add(app, change_handler): + """Commands can be added to a commandset""" + # Put some commands into the app + cmd_a = toga.Command(None, text="App command a") + cmd_b = toga.Command(None, text="App command b", order=10) + app.commands.add(cmd_a, cmd_b) + assert list(app.commands) == [cmd_a, cmd_b] + + # Create a standalone command set and add some commands + cs = CommandSet(on_change=change_handler) + cmd1a = toga.Command(None, text="Test command 1a", order=3) + cmd1b = toga.Command(None, text="Test command 1b", order=1) + cs.add(cmd1a, cmd1b) + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set has commands, and the order is the opposite to the insertion order. + assert list(cs) == [cmd1b, cmd1a] + + # New Commands aren't known to the app yet + assert list(app.commands) == [cmd_a, cmd_b] + + # Assign the commandset to the app + cs.app = app + + # Commands are now known to the app + assert list(app.commands) == [cmd_a, cmd1b, cmd1a, cmd_b] + + # Add another command to the commandset + cmd2 = toga.Command(None, text="Test command 2", order=2) + cs.add(cmd2) + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set has commands, and the output is ordered. + assert list(cs) == [cmd1b, cmd2, cmd1a] + + # App also knows about the command + assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] + + +def test_ordering(parent_group_1, parent_group_2, child_group_1, child_group_2): + """Ordering of groups, breaks and commands is preserved""" + + command_a = toga.Command(None, "A", group=parent_group_2, order=1) + command_b = toga.Command(None, "B", group=child_group_1, section=2, order=1) + command_s = toga.Command(None, "S", group=parent_group_1, order=5) + command_t = toga.Command(None, "T", group=child_group_2, order=2) + command_u = toga.Command(None, "U", group=child_group_2, order=1) + command_v = toga.Command(None, "V", group=parent_group_1, order=3) + command_w = toga.Command(None, "W", group=child_group_1, order=4) + command_x = toga.Command(None, "X", group=child_group_1, order=2) + command_y = toga.Command(None, "Y", group=child_group_1, order=1) + command_z = toga.Command(None, "Z", group=parent_group_1, order=1) + + commands = [ + command_z, + command_y, + command_x, + command_w, + command_b, + command_v, + command_u, + command_t, + command_s, + command_a, + ] + + # Do this a couple of times to make sure insertion order doesn't matter + for _ in range(0, 10): + random.shuffle(commands) + cs = CommandSet() + cs.add(*commands) + + assert list(cs) == [ + command_z, + GROUP_BREAK, + command_y, + command_x, + command_w, + SECTION_BREAK, + command_b, + GROUP_BREAK, + command_v, + GROUP_BREAK, + command_u, + command_t, + GROUP_BREAK, + command_s, + GROUP_BREAK, + command_a, + ] diff --git a/core/tests/command/test_group.py b/core/tests/command/test_group.py new file mode 100644 index 0000000000..3b2e29ec8c --- /dev/null +++ b/core/tests/command/test_group.py @@ -0,0 +1,290 @@ +import pytest + +import toga + +from .test_command import assert_order + + +def test_create(): + """A group can be created with defaults""" + grp = toga.Group("Group name") + assert grp.text == "Group name" + assert grp.order == 0 + + assert repr(grp) == "" + + +def test_create_with_params(): + """A fully specified group can be created""" + parent = toga.Group("Parent name") + grp = toga.Group("Group name", order=2, section=3, parent=parent) + + assert grp.text == "Group name" + assert grp.order == 2 + assert grp.section == 3 + assert grp.parent == parent + + assert ( + repr(grp) + == " section=3>" + ) + + +def test_create_section_without_parent(): + """A group cannot be created with a section but no parent.""" + with pytest.raises( + ValueError, + match=r"Section cannot be set without parent group", + ): + toga.Group("Group name", order=2, section=3) + + +def test_hashable(): + """Groups are hashable.""" + grp1 = toga.Group("text 1") + grp2 = toga.Group("text 2") + + # The hash is based on the full path, not just the text. + # This allows texts to be non-unique, as long as they're in + # different groups + grp1_child = toga.Group("text", parent=grp1) + grp2_child = toga.Group("text", parent=grp2) + + # Insert the groups as keys in a dict. This is + # only possible if Group is hashable. + groups = { + grp1: "First", + grp2: "Second", + grp1_child: "Child of 1", + grp2_child: "Child of 2", + } + + assert groups[grp1] == "First" + assert groups[grp2] == "Second" + assert groups[grp1_child] == "Child of 1" + assert groups[grp2_child] == "Child of 2" + + +def test_group_eq(): + """Groups can be comared for equality.""" + group_a = toga.Group("A") + group_b = toga.Group("B") + group_a1 = toga.Group("A", 1) + # Assign None to variable to trick flake8 into not giving an E711 + other = None + + # Same instance is equal + assert group_a == group_a + assert group_a1 == group_a1 + + # Same values are equal + assert group_a == toga.Group("A") + assert group_a1 == toga.Group("A", 1) + + # Different values are not equal + assert group_a != group_b + assert group_a != other + + # Partially same values are not equal + assert group_a1 != group_a + assert group_a1 != toga.Group("B", 1) + assert group_a1 != toga.Group("A", 2) + + +def test_parent_creation(): + """Parents can be assigned at creation""" + group_a = toga.Group("A") + group_b = toga.Group("B", parent=group_a) + group_c = toga.Group("C", parent=group_b) + + # None checks + assert not group_a.is_parent_of(None) + assert not group_a.is_child_of(None) + + # Parent relationships + assert group_a.is_parent_of(group_b) + assert group_b.is_parent_of(group_c) + assert group_a.is_parent_of(group_c) # grandparent + + # Child relationships + assert group_b.is_child_of(group_a) + assert group_c.is_child_of(group_b) + assert group_c.is_child_of(group_a) # grandchild + + # Reverse direction relationships aren't true + assert not group_a.is_child_of(group_b) + assert not group_b.is_child_of(group_c) + assert not group_a.is_child_of(group_c) + + assert not group_b.is_parent_of(group_a) + assert not group_c.is_parent_of(group_b) + assert not group_c.is_parent_of(group_a) + + assert group_a.parent is None + assert group_a.root == group_a + + assert group_b.parent == group_a + assert group_b.root == group_a + + assert group_c.parent == group_b + assert group_c.root == group_a + + +def test_parent_assignment(): + """Parents can be assigned at runtime""" + # Eventually, we'll end up with A->B->C, D. + group_a = toga.Group("A") + group_b = toga.Group("B") + group_c = toga.Group("C") + group_d = toga.Group("D") + + assert not group_a.is_parent_of(group_b) + assert not group_b.is_parent_of(group_c) + assert not group_a.is_parent_of(group_c) + assert not group_b.is_parent_of(group_d) + assert not group_a.is_parent_of(group_d) + + assert not group_b.is_child_of(group_a) + assert not group_c.is_child_of(group_b) + assert not group_c.is_child_of(group_a) + assert not group_d.is_child_of(group_b) + assert not group_d.is_child_of(group_a) + + assert not group_a.is_child_of(group_b) + assert not group_b.is_child_of(group_c) + assert not group_a.is_child_of(group_c) + assert not group_b.is_child_of(group_d) + assert not group_a.is_child_of(group_d) + + assert not group_b.is_parent_of(group_a) + assert not group_c.is_parent_of(group_b) + assert not group_c.is_parent_of(group_a) + assert not group_d.is_parent_of(group_b) + assert not group_d.is_parent_of(group_a) + + assert group_a.parent is None + assert group_a.root == group_a + + assert group_b.parent is None + assert group_b.root == group_b + + assert group_c.parent is None + assert group_c.root == group_c + + # Assign parents. + # C is assigned to B *before* B is assigned to A. + # D is assigned to B *after* B is assigned to A. + # This ensures that root isn't preserved in an intermediate state + group_c.parent = group_b + group_b.parent = group_a + group_d.parent = group_b + + assert group_a.is_parent_of(group_b) + assert group_b.is_parent_of(group_c) + assert group_a.is_parent_of(group_c) # grandparent + assert group_b.is_parent_of(group_d) + assert group_a.is_parent_of(group_d) # grandparent + + assert group_b.is_child_of(group_a) + assert group_c.is_child_of(group_b) + assert group_c.is_child_of(group_a) # grandchild + assert group_d.is_child_of(group_b) + assert group_d.is_child_of(group_a) # grandchild + + # Reverse direction relationships aren't true + assert not group_a.is_child_of(group_b) + assert not group_b.is_child_of(group_c) + assert not group_a.is_child_of(group_c) + assert not group_b.is_child_of(group_d) + assert not group_a.is_child_of(group_d) + + assert not group_b.is_parent_of(group_a) + assert not group_c.is_parent_of(group_b) + assert not group_c.is_parent_of(group_a) + assert not group_d.is_parent_of(group_b) + assert not group_d.is_parent_of(group_a) + + assert group_a.parent is None + assert group_a.root == group_a + + assert group_b.parent == group_a + assert group_b.root == group_a + + assert group_c.parent == group_b + assert group_c.root == group_a + + assert group_d.parent == group_b + assert group_d.root == group_a + + +def test_parent_loops(): + """Parent loops are prevented can be assigned at runtime""" + group_a = toga.Group("A") + group_b = toga.Group("B", parent=group_a) + group_c = toga.Group("C", parent=group_b) + + # + with pytest.raises( + ValueError, + match=r"A group cannot be it's own parent", + ): + group_a.parent = group_a + + with pytest.raises( + ValueError, + match=r"Cannot set parent; 'A' is an ancestor of 'B'.", + ): + group_a.parent = group_b + + with pytest.raises( + ValueError, + match=r"Cannot set parent; 'A' is an ancestor of 'C'.", + ): + group_a.parent = group_c + + +def test_order_by_text(): + """Groups are ordered by text if order and section are equivalent""" + assert_order(toga.Group("A"), toga.Group("B")) + + +def test_order_by_number(): + """Groups are ordered by number""" + assert_order(toga.Group("B", 1), toga.Group("A", 2)) + + +def test_order_by_groups(parent_group_1, parent_group_2): + """Groups are ordered by parent, then section, then order.""" + assert_order( + parent_group_1, + toga.Group("C", parent=parent_group_1), + toga.Group("D", parent=parent_group_1), + toga.Group("AA3", parent=parent_group_1, section=2), + toga.Group("AA2", parent=parent_group_1, section=3, order=1), + toga.Group("AA1", parent=parent_group_1, section=3, order=2), + parent_group_2, + toga.Group("B", parent=parent_group_2), + ) + + +# def test_cannot_set_self_as_parent(self): +# group = toga.Group("P") +# with self.assertRaises(ValueError): +# group.parent = group +# self.assert_parent_and_root(group, None, group) + + +# def test_cannot_set_child_to_be_a_parent_of_its_grandparent(self): +# grandparent = toga.Group("G") +# parent = toga.Group("P", parent=grandparent) +# child = toga.Group("C", parent=parent) +# with self.assertRaises(ValueError): +# grandparent.parent = child +# self.assert_parent_and_root(grandparent, None, grandparent) +# self.assert_parent_and_root(parent, grandparent, grandparent) +# self.assert_parent_and_root(child, parent, grandparent) + + +# def assert_parent_and_root(self, group, parent, root): +# self.assertEqual(group.parent, parent) +# self.assertEqual(group.root, root) diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index e517bc6ba0..f474bf82fa 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -14,18 +14,6 @@ def setUp(self): ###################################################################### # factory no longer used - def test_command(self): - with self.assertWarns(DeprecationWarning): - widget = toga.Command(self.callback, "Test", factory=self.factory) - with self.assertWarns(DeprecationWarning): - widget.bind(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - - def test_command_set(self): - with self.assertWarns(DeprecationWarning): - toga.CommandSet(factory=self.factory) - def test_font(self): widget = toga.Font(SANS_SERIF, 14) with self.assertWarns(DeprecationWarning): diff --git a/core/tests/utils.py b/core/tests/utils.py deleted file mode 100644 index 504132d874..0000000000 --- a/core/tests/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio - - -def async_test(coroutine): - """Run an async test to completion.""" - - def _test(self): - asyncio.run(coroutine(self)) - - return _test - - -def order_test(*items): - def _test(self): - for i in range(0, len(items) - 1): - for j in range(i + 1, len(items)): - self.assertLess(items[i], items[j]) - self.assertGreater(items[j], items[i]) - self.assertFalse(items[j] < items[i]) - self.assertFalse(items[i] > items[j]) - - return _test diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index a6b2d15a99..f2c425f542 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -142,6 +142,8 @@ it wasn't explicitly added to the app commands. Reference --------- +.. autoprotocol:: toga.command.ActionHandler + .. autoclass:: toga.Command :members: :undoc-members: @@ -150,6 +152,8 @@ Reference :members: :undoc-members: -.. autoclass:: toga.CommandSet +.. autoprotocol:: toga.command.CommandSetChangeHandler + +.. autoclass:: toga.command.CommandSet :members: :undoc-members: From f8c19b37934168dd1c9282332c14be5e5c6cc75b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 12 Aug 2023 11:35:04 +0800 Subject: [PATCH 08/66] Remove CommandSet, GROUP_BREAK and SECTION_BREAK from public namespace. --- android/src/toga_android/app.py | 6 +++--- changes/2075.removal.4.rst | 1 + cocoa/src/toga_cocoa/app.py | 5 +++-- core/src/toga/__init__.py | 5 +---- web/src/toga_web/app.py | 5 +++-- winforms/src/toga_winforms/app.py | 5 +++-- 6 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 changes/2075.removal.4.rst diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 269bf49171..dd70061f24 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -3,7 +3,7 @@ from rubicon.java import android_events import toga -from toga.command import Group +from toga.command import GROUP_BREAK, SECTION_BREAK, Group from .libs.activity import IPythonApp, MainActivity from .libs.android.graphics import Drawable @@ -92,7 +92,7 @@ def onPrepareOptionsMenu(self, menu): # create option menu for cmd in self._impl.interface.commands: - if cmd == toga.SECTION_BREAK or cmd == toga.GROUP_BREAK: + if cmd == SECTION_BREAK or cmd == GROUP_BREAK: continue if cmd in self._impl.interface.main_window.toolbar: continue # do not show toolbar commands in the option menu (except when overflowing) @@ -138,7 +138,7 @@ def onPrepareOptionsMenu(self, menu): # create toolbar actions if self._impl.interface.main_window: for cmd in self._impl.interface.main_window.toolbar: - if cmd == toga.SECTION_BREAK or cmd == toga.GROUP_BREAK: + if cmd == SECTION_BREAK or cmd == GROUP_BREAK: continue itemid += 1 order = Menu.NONE if cmd.order is None else cmd.order diff --git a/changes/2075.removal.4.rst b/changes/2075.removal.4.rst new file mode 100644 index 0000000000..288fd809a1 --- /dev/null +++ b/changes/2075.removal.4.rst @@ -0,0 +1 @@ +``GROUP_BREAK``, ``SECTION_BREAK`` and ``CommandSet`` were removed from the ``toga`` namespace. End users generally shouldn't need to use these classes. If your code *does* need them for some reason, you can access them from the ``toga.command`` namespace. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index b42fe62e99..60de706599 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -8,6 +8,7 @@ from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy import toga +from toga.command import GROUP_BREAK, SECTION_BREAK from toga.handlers import NativeHandler from .keys import cocoa_key @@ -321,9 +322,9 @@ def create_menus(self): menubar = NSMenu.alloc().initWithTitle("MainMenu") submenu = None for cmd in self.interface.commands: - if cmd == toga.GROUP_BREAK: + if cmd == GROUP_BREAK: submenu = None - elif cmd == toga.SECTION_BREAK: + elif cmd == SECTION_BREAK: submenu.addItem_(NSMenuItem.separatorItem()) else: submenu = self._submenu(cmd.group, menubar) diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index dbfa29f41e..705ee52e06 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -2,7 +2,7 @@ # Resources from .colors import hsl, hsla, rgb, rgba -from .command import GROUP_BREAK, SECTION_BREAK, Command, CommandSet, Group +from .command import Command, Group from .documents import Document from .fonts import Font from .icons import Icon @@ -45,10 +45,7 @@ "DocumentMainWindow", # Commands "Command", - "CommandSet", "Group", - "GROUP_BREAK", - "SECTION_BREAK", # Documents "Document", # Keys diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index fdb71cba10..839892acc1 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -1,4 +1,5 @@ import toga +from toga.command import GROUP_BREAK, SECTION_BREAK from toga_web.libs import create_element, js from toga_web.window import Window @@ -63,9 +64,9 @@ def create_menus(self): submenu = None for cmd in self.interface.commands: - if cmd == toga.GROUP_BREAK: + if cmd == GROUP_BREAK: submenu = None - elif cmd == toga.SECTION_BREAK: + elif cmd == SECTION_BREAK: # TODO - add a section break pass else: diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 68f85c6f93..f30b8d41ce 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -5,6 +5,7 @@ import toga from toga import Key +from toga.command import GROUP_BREAK, SECTION_BREAK from .keys import toga_to_winforms_key from .libs import ( @@ -140,9 +141,9 @@ def create_menus(self): menubar = WinForms.MenuStrip() submenu = None for cmd in self.interface.commands: - if cmd == toga.GROUP_BREAK: + if cmd == GROUP_BREAK: submenu = None - elif cmd == toga.SECTION_BREAK: + elif cmd == SECTION_BREAK: submenu.DropDownItems.Add("-") else: submenu = self._submenu(cmd.group, menubar) From e0a46dc48c610a44204a6fcdccac6f588c31fbaf Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 12 Aug 2023 11:48:25 +0800 Subject: [PATCH 09/66] Corrected some coverage issues with a late change to comparisons. --- core/src/toga/command.py | 10 +++++----- core/tests/command/test_command.py | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 45bcc0096d..f51f0e5e5b 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -102,17 +102,17 @@ def __hash__(self) -> int: return hash(self.key) def __lt__(self, other: Any) -> bool: - if not isinstance(other, [Group, Command]): + if not isinstance(other, (Group, Command)): return False return self.key < other.key def __gt__(self, other: Any) -> bool: - if not isinstance(other, [Group, Command]): + if not isinstance(other, (Group, Command)): return False return other < self def __eq__(self, other: Any) -> bool: - if not isinstance(other, [Group, Command]): + if not isinstance(other, (Group, Command)): return False return self.key == other.key @@ -241,12 +241,12 @@ def icon(self, icon_or_name: str | Icon): self._icon = Icon(icon_or_name) def __lt__(self, other: Any) -> bool: - if not isinstance(other, [Group, Command]): + if not isinstance(other, (Group, Command)): return False return self.key < other.key def __gt__(self, other: Any) -> bool: - if not isinstance(other, [Group, Command]): + if not isinstance(other, (Group, Command)): return False return other < self diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index 64a83c9162..c230feb347 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -13,6 +13,12 @@ def assert_order(*items): assert items[i] < items[j] assert items[j] > items[i] + # For good measure; check comparisons with other types + assert not items[i] < None + assert not items[i] < 42 + assert not items[i] > None + assert not items[i] > 42 + @pytest.fixture def app(): From fec495d34b74f7067ce0b0223dc4ae3be48844b5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 12 Aug 2023 14:51:17 +0800 Subject: [PATCH 10/66] Factored out a common app fixture. --- core/tests/app/test_app.py | 7 +--- core/tests/app/test_mainwindow.py | 5 --- core/tests/app/test_windowset.py | 5 --- core/tests/command/test_command.py | 5 --- core/tests/command/test_commandset.py | 5 --- core/tests/conftest.py | 19 +++++++++ core/tests/test_documents.py | 5 --- core/tests/test_images.py | 5 --- core/tests/test_window.py | 7 +--- core/tests/widgets/test_base.py | 49 +++++++--------------- core/tests/widgets/test_imageview.py | 5 --- core/tests/widgets/test_optioncontainer.py | 5 --- core/tests/widgets/test_scrollcontainer.py | 5 --- core/tests/widgets/test_splitcontainer.py | 5 --- 14 files changed, 37 insertions(+), 95 deletions(-) diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 2722fc5b50..d04f5c167c 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -29,11 +29,6 @@ } -@pytest.fixture -def app(): - return toga.App(formal_name="Test App", app_id="org.example.test") - - @pytest.mark.parametrize( ( "kwargs, metadata, main_module, expected_formal_name, expected_app_id, expected_app_name, " @@ -399,7 +394,7 @@ def test_icon(app, construct): # Default icon matches app name assert isinstance(app.icon, toga.Icon) - assert app.icon.path == "resources/test" + assert app.icon.path == "resources/test_app" # Change icon app.icon = icon diff --git a/core/tests/app/test_mainwindow.py b/core/tests/app/test_mainwindow.py index 99261279ad..f8be71bdeb 100644 --- a/core/tests/app/test_mainwindow.py +++ b/core/tests/app/test_mainwindow.py @@ -6,11 +6,6 @@ from toga_dummy.utils import assert_action_performed -@pytest.fixture -def app(): - return toga.App("Test App", "org.beeware.toga.app.main_window") - - def test_create(app): "A MainWindow can be created with minimal arguments" window = toga.MainWindow() diff --git a/core/tests/app/test_windowset.py b/core/tests/app/test_windowset.py index 9a69270081..1fec5550d5 100644 --- a/core/tests/app/test_windowset.py +++ b/core/tests/app/test_windowset.py @@ -4,11 +4,6 @@ from toga.app import WindowSet -@pytest.fixture -def app(): - return toga.App("Test App", "org.beeware.toga.app.main_window") - - @pytest.fixture def window1(): return toga.Window(title="Window 1") diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index c230feb347..ee81b5c66a 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -20,11 +20,6 @@ def assert_order(*items): assert not items[i] > 42 -@pytest.fixture -def app(): - return toga.App("Command Test", "org.beeware.command") - - def test_break(): """A break can be created""" diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py index 8725f91907..4445f359ed 100644 --- a/core/tests/command/test_commandset.py +++ b/core/tests/command/test_commandset.py @@ -7,11 +7,6 @@ from toga.command import GROUP_BREAK, SECTION_BREAK, CommandSet -@pytest.fixture -def app(): - return toga.App("CommandSet Test", "org.beeware.commandset") - - def test_create(): """A CommandSet can be created with defaults""" cs = CommandSet() diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 59c769cff1..e8f8aabe89 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -1,8 +1,27 @@ +import sys + import pytest +import toga from toga_dummy.utils import EventLog @pytest.fixture(autouse=True) def reset_event_log(): EventLog.reset() + + +@pytest.fixture(autouse=True) +def clear_sys_modules(monkeypatch): + try: + # App startup is influenced by things like the state of sys.modules, and the + # presence of __main__ in particular. Pytest doesn't need __main__ to work; + # so if it exists, delete it for the purposes of each test. + monkeypatch.delitem(sys.modules, "__main__") + except KeyError: + pass + + +@pytest.fixture +def app(event_loop): + return toga.App(formal_name="Test App", app_id="org.beeware.toga.test-app") diff --git a/core/tests/test_documents.py b/core/tests/test_documents.py index b962a51a9b..a0da06a9a9 100644 --- a/core/tests/test_documents.py +++ b/core/tests/test_documents.py @@ -17,11 +17,6 @@ def read(self): pass -@pytest.fixture -def app(): - return toga.App("Document Test", "org.beeware.toga.documents") - - @pytest.mark.parametrize("path", ["/path/to/doc.mydoc", Path("/path/to/doc.mydoc")]) def test_create_document(app, path): doc = MyDoc(path, app) diff --git a/core/tests/test_images.py b/core/tests/test_images.py index 787cc2ed59..6f5d371763 100644 --- a/core/tests/test_images.py +++ b/core/tests/test_images.py @@ -9,11 +9,6 @@ ABSOLUTE_FILE_PATH = Path(toga.__file__).parent / "resources" / "toga.png" -@pytest.fixture -def app(): - return toga.App("Images Test", "org.beeware.toga.images") - - @pytest.mark.parametrize( "args, kwargs", [ diff --git a/core/tests/test_window.py b/core/tests/test_window.py index a5f38bd3ee..a6ab321ed9 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -11,11 +11,6 @@ ) -@pytest.fixture -def app(event_loop): - return toga.App("Test App", "org.beeware.toga.window") - - @pytest.fixture def window(): return toga.Window() @@ -83,7 +78,7 @@ def test_set_app(window, app): assert window.app == app - app2 = toga.App("Test App 2", "org.beeware.toga.window2") + app2 = toga.App("Test App 2", "org.beeware.toga.test-app-2") with pytest.raises(ValueError, match=r"Window is already associated with an App"): window.app = app2 diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index 9b547930f2..70be52b347 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -110,10 +110,9 @@ def test_add_child_without_app(widget): assert_action_performed_with(widget, "refresh") -def test_add_child(widget): +def test_add_child(app, widget): "A child can be added to a node when there's an app & window" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -160,10 +159,9 @@ def test_add_child(widget): assert app.widgets["child_id"] == child -def test_add_multiple_children(widget): +def test_add_multiple_children(app, widget): "Multiple children can be added in one call" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -333,10 +331,9 @@ def test_insert_child_without_app(widget): assert_action_performed_with(widget, "refresh") -def test_insert_child(widget): +def test_insert_child(app, widget): "A child can be inserted into a node when there's an app & window" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -383,10 +380,9 @@ def test_insert_child(widget): assert app.widgets["child_id"] == child -def test_insert_position(widget): +def test_insert_position(app, widget): "Insert can put a child into a specific position" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -451,10 +447,9 @@ def test_insert_position(widget): assert app.widgets["child3_id"] == child3 -def test_insert_bad_position(widget): +def test_insert_bad_position(app, widget): "If the position is invalid, an error is raised" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -588,13 +583,12 @@ def test_remove_child_without_app(widget): assert_action_performed_with(widget, "refresh") -def test_remove_child(widget): +def test_remove_child(app, widget): "A child associated with an app & window can be removed from a widget" # Add a child to the widget child = ExampleLeafWidget(id="child_id") widget.add(child) - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -627,7 +621,7 @@ def test_remove_child(widget): assert_action_performed_with(window.content, "refresh") -def test_remove_multiple_children(widget): +def test_remove_multiple_children(app, widget): "Multiple children can be removed from a widget" # Add children to the widget child1 = ExampleLeafWidget(id="child1_id") @@ -635,7 +629,6 @@ def test_remove_multiple_children(widget): child3 = ExampleLeafWidget(id="child3_id") widget.add(child1, child2, child3) - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -678,7 +671,7 @@ def test_remove_multiple_children(widget): assert_action_performed_with(window.content, "refresh") -def test_clear_all_children(widget): +def test_clear_all_children(app, widget): "All children can be simultaneously removed from a widget" # Add children to the widget child1 = ExampleLeafWidget(id="child1_id") @@ -686,7 +679,6 @@ def test_clear_all_children(widget): child3 = ExampleLeafWidget(id="child3_id") widget.add(child1, child2, child3) - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -730,9 +722,8 @@ def test_clear_all_children(widget): assert_action_performed_with(window.content, "refresh") -def test_clear_no_children(widget): +def test_clear_no_children(app, widget): "No changes are made (no-op) if widget has no children" - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = widget @@ -754,10 +745,9 @@ def test_clear_no_children(widget): assert_action_not_performed(window.content, "refresh") -def test_clear_leaf_node(): +def test_clear_leaf_node(app): "No changes are made to leaf node that cannot have children" leaf = ExampleLeafWidget() - app = toga.App("Test", "com.example.test") window = toga.Window() window.app = app window.content = leaf @@ -805,9 +795,8 @@ def test_remove_from_non_parent(widget): assert_action_not_performed(widget, "refresh") -def test_set_app(widget): +def test_set_app(app, widget): "A widget can be assigned to an app" - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -824,7 +813,7 @@ def test_set_app(widget): assert attribute_value(widget, "app") == app -def test_set_app_with_children(widget): +def test_set_app_with_children(app, widget): "If a widget has children, the children get the app assignment" # Add children to the widget child1 = ExampleLeafWidget(id="child1_id") @@ -832,8 +821,6 @@ def test_set_app_with_children(widget): child3 = ExampleLeafWidget(id="child3_id") widget.add(child1, child2, child3) - # Set up an app - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -861,9 +848,8 @@ def test_set_app_with_children(widget): assert attribute_value(child3, "app") == app -def test_set_same_app(widget): +def test_set_same_app(app, widget): "A widget can be re-assigned to the same app" - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -879,9 +865,8 @@ def test_set_same_app(widget): assert_attribute_not_set(widget, "app") -def test_reset_app(widget): +def test_reset_app(app, widget): "A widget can be re-assigned to no app" - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -903,10 +888,8 @@ def test_reset_app(widget): assert attribute_value(widget, "app") is None -def test_set_new_app(widget): +def test_set_new_app(app, widget): "A widget can be assigned to a different app" - app = toga.App("Test App", "org.beeware.test") - # Assign the widget to an app widget.app = app assert len(app.widgets) == 1 @@ -915,7 +898,7 @@ def test_set_new_app(widget): EventLog.reset() # Create a new app - new_app = toga.App("Test App", "org.beeware.test") + new_app = toga.App("Test App", "org.beeware.toga.test-app") assert len(new_app.widgets) == 0 # Assign the widget to the same app diff --git a/core/tests/widgets/test_imageview.py b/core/tests/widgets/test_imageview.py index 447d4542e2..f4d99c29c2 100644 --- a/core/tests/widgets/test_imageview.py +++ b/core/tests/widgets/test_imageview.py @@ -14,11 +14,6 @@ ) -@pytest.fixture -def app(): - return toga.App("ImageView Test", "org.beeware.toga.widgets.imageview") - - @pytest.fixture def widget(app): return toga.ImageView() diff --git a/core/tests/widgets/test_optioncontainer.py b/core/tests/widgets/test_optioncontainer.py index 505f34d64c..4431b238fd 100644 --- a/core/tests/widgets/test_optioncontainer.py +++ b/core/tests/widgets/test_optioncontainer.py @@ -10,11 +10,6 @@ ) -@pytest.fixture -def app(): - return toga.App("Option Container Test", "org.beeware.toga.option_container") - - @pytest.fixture def window(): return toga.Window() diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index 114049aff6..024701bce9 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -11,11 +11,6 @@ ) -@pytest.fixture -def app(): - return toga.App("Scroll Container Test", "org.beeware.toga.scroll_container") - - @pytest.fixture def window(): return toga.Window() diff --git a/core/tests/widgets/test_splitcontainer.py b/core/tests/widgets/test_splitcontainer.py index 6f6f88263c..e53b9a687c 100644 --- a/core/tests/widgets/test_splitcontainer.py +++ b/core/tests/widgets/test_splitcontainer.py @@ -8,11 +8,6 @@ ) -@pytest.fixture -def app(): - return toga.App("Split Container Test", "org.beeware.toga.split_container") - - @pytest.fixture def window(): return toga.Window() From dde078e064d37ed6e19e6954aef9d5a19a52d8f3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 15 Aug 2023 11:20:42 +0800 Subject: [PATCH 11/66] Cocoa to 100% (ish) coverage. --- cocoa/src/toga_cocoa/app.py | 61 ++-- cocoa/src/toga_cocoa/command.py | 14 +- cocoa/src/toga_cocoa/documents.py | 4 +- cocoa/src/toga_cocoa/window.py | 60 +-- cocoa/tests_backend/app.py | 98 +++++ cocoa/tests_backend/window.py | 27 ++ core/src/toga/app.py | 5 + core/src/toga/command.py | 10 +- core/tests/command/test_commandset.py | 18 +- testbed/tests/conftest.py | 15 + testbed/tests/test_app.py | 504 ++++++++++++++++++++++++++ testbed/tests/test_window.py | 32 +- testbed/tests/testbed.py | 2 +- 13 files changed, 787 insertions(+), 63 deletions(-) create mode 100644 testbed/tests/test_app.py diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 60de706599..893ca2b038 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -18,6 +18,7 @@ NSAboutPanelOptionApplicationIcon, NSAboutPanelOptionApplicationName, NSAboutPanelOptionApplicationVersion, + NSAboutPanelOptionVersion, NSApplication, NSApplicationActivationPolicyRegular, NSBeep, @@ -59,21 +60,21 @@ def applicationDidFinishLaunching_(self, notification): self.native.activateIgnoringOtherApps(True) @objc_method - def applicationOpenUntitledFile_(self, sender) -> bool: + def applicationOpenUntitledFile_(self, sender) -> bool: # pragma: no cover self.impl.select_file() return True @objc_method - def addDocument_(self, document) -> None: + def addDocument_(self, document) -> None: # pragma: no cover # print("Add Document", document) super().addDocument_(document) @objc_method - def applicationShouldOpenUntitledFile_(self, sender) -> bool: + def applicationShouldOpenUntitledFile_(self, sender) -> bool: # pragma: no cover return True @objc_method - def application_openFiles_(self, app, filenames) -> None: + def application_openFiles_(self, app, filenames) -> None: # pragma: no cover for i in range(0, len(filenames)): filename = filenames[i] # If you start your Toga application as `python myapp.py` or @@ -100,8 +101,7 @@ def application_openFiles_(self, app, filenames) -> None: @objc_method def selectMenuItem_(self, sender) -> None: cmd = self.impl._menu_items[sender] - if cmd.action: - cmd.action(None) + cmd.action(None) @objc_method def validateMenuItem_(self, sender) -> bool: @@ -290,7 +290,7 @@ def _create_app_commands(self): ), # ---- Help menu ---------------------------------- toga.Command( - lambda _, **kwargs: self.interface.visit_homepage(), + self._menu_visit_homepage, "Visit homepage", enabled=self.interface.home_page is not None, group=toga.Group.HELP, @@ -308,13 +308,17 @@ def _menu_close_window(self, app, **kwargs): self.interface.current_window._impl.native.performClose(None) def _menu_close_all_windows(self, app, **kwargs): - for window in self.interface.windows: + # Convert to a list to so that we're not altering a set while iterating + for window in list(self.interface.windows): window._impl.native.performClose(None) def _menu_minimize(self, app, **kwargs): if self.interface.current_window: self.interface.current_window._impl.native.miniaturize(None) + def _menu_visit_homepage(self, app, **kwargs): + self.interface.visit_homepage() + def create_menus(self): # Recreate the menu self._menu_items = {} @@ -351,6 +355,13 @@ def create_menus(self): if modifier is not None: item.keyEquivalentModifierMask = modifier + # Explicit set the initial enabled/disabled state on the menu item + item.setEnabled(cmd.enabled) + + # Associated the MenuItem with the command, so that future + # changes to enabled etc are reflected. + cmd._impl.native.add(item) + self._menu_items[item] = cmd submenu.addItem(item) @@ -393,28 +404,28 @@ def show_about_dialog(self): options = NSMutableDictionary.alloc().init() options[NSAboutPanelOptionApplicationIcon] = self.interface.icon._impl.native + options[NSAboutPanelOptionApplicationName] = self.interface.name - if self.interface.name is not None: - options[NSAboutPanelOptionApplicationName] = self.interface.name - - if self.interface.version is not None: + if self.interface.version is None: + options[NSAboutPanelOptionApplicationVersion] = "0.0" + else: options[NSAboutPanelOptionApplicationVersion] = self.interface.version # The build number - # if self.interface.version is not None: - # options[NSAboutPanelOptionVersion] = "the build" + options[NSAboutPanelOptionVersion] = "1" - if self.interface.author is not None: - options["Copyright"] = "Copyright © {author}".format( - author=self.interface.author - ) + if self.interface.author is None: + options["Copyright"] = "" + else: + options["Copyright"] = f"Copyright © {self.interface.author}" self.native.orderFrontStandardAboutPanelWithOptions(options) def beep(self): NSBeep() - def exit(self): + # We can't call this under test conditions, because it would kill the test harness + def exit(self): # pragma: no cover self.loop.stop() def get_current_window(self): @@ -472,12 +483,12 @@ def select_file(self, **kwargs): """No-op when the app is not a ``DocumentApp``.""" -class DocumentApp(App): +class DocumentApp(App): # pragma: no cover def _create_app_commands(self): super()._create_app_commands() self.interface.commands.add( toga.Command( - lambda _: self.select_file(), + self._menu_open_file, text="Open...", shortcut=toga.Key.MOD_1 + "o", group=toga.Group.FILE, @@ -485,6 +496,9 @@ def _create_app_commands(self): ), ) + 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.. @@ -506,11 +520,6 @@ def select_file(self, **kwargs): self.appDelegate.application_openFiles_(None, panel.URLs) def open_document(self, fileURL): - """Open a new document in this app. - - Args: - fileURL (str): The URL/path to the file to add as a document. - """ # Convert a cocoa fileURL to a file path. fileURL = fileURL.rstrip("/") path = Path(unquote(urlparse(fileURL).path)) diff --git a/cocoa/src/toga_cocoa/command.py b/cocoa/src/toga_cocoa/command.py index 8575d29e27..876a7226d5 100644 --- a/cocoa/src/toga_cocoa/command.py +++ b/cocoa/src/toga_cocoa/command.py @@ -1,7 +1,17 @@ +from toga_cocoa.libs import NSMenuItem + + class Command: def __init__(self, interface): self.interface = interface - self.native = [] + self.native = set() def set_enabled(self, value): - pass + for item in self.native: + if isinstance(item, NSMenuItem): + # Menu item enabled status is determined by the app delegate + item.menu.update() + else: + # Otherwise, assume the native object has + # and explicit enabled property + item.setEnabled(value) diff --git a/cocoa/src/toga_cocoa/documents.py b/cocoa/src/toga_cocoa/documents.py index 03b546afd7..efa7b91092 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): +class TogaDocument(NSDocument): # pragma: no cover interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -20,7 +20,7 @@ def readFromFileWrapper_ofType_error_( return True -class Document: +class Document: # pragma: no cover # macOS has multiple documents in a single app instance. SINGLE_DOCUMENT_APP = False diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 3ac7332313..81a788c872 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,4 +1,4 @@ -from toga.command import Command as BaseCommand +from toga.command import Command from toga_cocoa.container import Container from toga_cocoa.libs import ( SEL, @@ -18,7 +18,10 @@ def toolbar_identifier(cmd): - return "ToolbarItem-%s" % id(cmd) + if isinstance(cmd, Command): + return "ToolbarItem-%s" % id(cmd) + else: + return "ToolbarSeparator-%s" % id(cmd) class TogaWindow(NSWindow): @@ -40,10 +43,11 @@ def windowDidResize_(self, notification) -> None: ###################################################################### @objc_method - def toolbarAllowedItemIdentifiers_(self, toolbar): + def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover """Determine the list of available toolbar items.""" - # This method is required by the Cocoa API, but isn't actually invoked, - # because customizable toolbars are no longer a thing. + # This method is required by the Cocoa API, but it's only ever called if the + # toolbar allows user customization. We don't turn that option on so this method + # can't ever be invoked - but we need to provide an implementation. allowed = NSMutableArray.alloc().init() for item in self.interface.toolbar: allowed.addObject_(toolbar_identifier(item)) @@ -59,25 +63,27 @@ def toolbarDefaultItemIdentifiers_(self, toolbar): @objc_method def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( - self, toolbar, identifier, insert: bool + self, + toolbar, + identifier, + insert: bool, ): """Create the requested toolbar button.""" native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) try: item = self.impl._toolbar_items[str(identifier)] - if item.text: - native.setLabel(item.text) - native.setPaletteLabel(item.text) + native.setLabel(item.text) + native.setPaletteLabel(item.text) if item.tooltip: native.setToolTip(item.tooltip) if item.icon: native.setImage(item.icon._impl.native) - item._impl.native.append(native) + item._impl.native.add(native) native.setTarget_(self) native.setAction_(SEL("onToolbarButtonPress:")) - except KeyError: + except KeyError: # Separator items pass # Prevent the toolbar item from being deallocated when @@ -142,21 +148,31 @@ def __init__(self, interface, title, position, size): self.container = Container(on_refresh=self.content_refreshed) self.native.contentView = self.container.native + # By default, no toolbar + self.native_toolbar = None + def __del__(self): self.native.release() def create_toolbar(self): - self._toolbar_items = {} - for cmd in self.interface.toolbar: - if isinstance(cmd, BaseCommand): - self._toolbar_items[toolbar_identifier(cmd)] = cmd - - self._toolbar_native = NSToolbar.alloc().initWithIdentifier( - "Toolbar-%s" % id(self) - ) - self._toolbar_native.setDelegate(self.native) - - self.native.setToolbar(self._toolbar_native) + if self.interface.toolbar: + self._toolbar_items = {} + 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 set_content(self, widget): # Set the content of the window's container diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index e06b9e1c2b..26616c510b 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -1,9 +1,14 @@ from pathlib import Path +from rubicon.objc import ObjCClass, send_message +from rubicon.objc.runtime import objc_id + from toga_cocoa.libs import NSApplication from .probe import BaseProbe +NSPanel = ObjCClass("NSPanel") + class AppProbe(BaseProbe): def __init__(self, app): @@ -28,3 +33,96 @@ def cache_path(self): @property def logs_path(self): return Path.home() / "Library" / "Logs" / "org.beeware.toga.testbed" + + @property + def is_cursor_visible(self): + # There's no API level mechanism to detect cursor visibility; + # fall back to the implementation's proxy variable. + return self.app._impl._cursor_visible + + def is_full_screen(self, window): + return window.content._impl.native.isInFullScreenMode() + + def content_size(self, window): + return ( + window.content._impl.native.frame.size.width, + window.content._impl.native.frame.size.height, + ) + + def _menu_item(self, path): + main_menu = self.app._impl.native.mainMenu + + menu = main_menu + orig_path = path.copy() + try: + while True: + label, path = path[0], path[1:] + item = menu.itemWithTitle(label) + menu = item.submenu + except IndexError: + pass + except AttributeError: + raise AssertionError(f"Menu {' > '.join(orig_path)} not found") + + return item + + def _activate_menu_item(self, path): + item = self._menu_item(path) + send_message( + self.app._impl.native.delegate, + item.action, + item, + restype=None, + argtypes=[objc_id], + ) + + def activate_menu_exit(self): + self._activate_menu_item(["*", "Quit Toga Testbed"]) + + def activate_menu_about(self): + self._activate_menu_item(["*", "About Toga Testbed"]) + + def close_about_dialog(self): + about_dialog = self.app._impl.native.keyWindow + if isinstance(about_dialog, NSPanel): + about_dialog.close() + + def activate_menu_visit_homepage(self): + self._activate_menu_item(["Help", "Visit homepage"]) + + def assert_system_menus(self): + self.assert_menu_item(["*", "About Toga Testbed"], enabled=True) + self.assert_menu_item(["*", "Settings\u2026"], enabled=False) + self.assert_menu_item(["*", "Hide Toga Testbed"], enabled=True) + self.assert_menu_item(["*", "Hide Others"], enabled=True) + self.assert_menu_item(["*", "Show All"], enabled=True) + self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) + + self.assert_menu_item(["File", "Close Window"], enabled=True) + self.assert_menu_item(["File", "Close All Windows"], enabled=True) + + self.assert_menu_item(["Edit", "Undo"], enabled=True) + self.assert_menu_item(["Edit", "Redo"], enabled=True) + self.assert_menu_item(["Edit", "Cut"], enabled=True) + self.assert_menu_item(["Edit", "Copy"], enabled=True) + self.assert_menu_item(["Edit", "Paste"], enabled=True) + self.assert_menu_item(["Edit", "Paste and Match Style"], enabled=True) + self.assert_menu_item(["Edit", "Delete"], enabled=True) + self.assert_menu_item(["Edit", "Select All"], enabled=True) + + self.assert_menu_item(["Window", "Minimize"], enabled=True) + + self.assert_menu_item(["Help", "Visit homepage"], enabled=True) + + def activate_menu_close_window(self): + self._activate_menu_item(["File", "Close Window"]) + + def activate_menu_close_all_windows(self): + self._activate_menu_item(["File", "Close All Windows"]) + + def activate_menu_minimize(self): + self._activate_menu_item(["Window", "Minimize"]) + + def assert_menu_item(self, path, enabled): + item = self._menu_item(path) + assert item.isEnabled() == enabled diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 55ec6208df..e40c6c6158 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -1,6 +1,8 @@ from unittest.mock import Mock +from rubicon.objc import send_message from rubicon.objc.collections import ObjCListInstance +from rubicon.objc.runtime import objc_id from toga_cocoa.libs import ( NSURL, @@ -247,3 +249,28 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): f"{'Multiselect' if multiple_select else ' Select'} folder dialog " f"({'OPEN' if result else 'CANCEL'}) dismissed" ) + + def has_toolbar(self): + return self.native.toolbar is not None + + def assert_is_toolbar_separator(self, index): + item = self.native.toolbar.items[index] + assert str(item.itemIdentifier).startswith("ToolbarSeparator-") + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self.native.toolbar.items[index] + + assert str(item.label) == label + assert (None if item.toolTip is None else str(item.toolTip)) == tooltip + assert (item.image is not None) == has_icon + assert item.isEnabled() == enabled + + def press_toolbar_button(self, index): + item = self.native.toolbar.items[index] + send_message( + item.target, + item.action, + item, + restype=None, + argtypes=[objc_id], + ) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index dc4c250d66..0f7b25cb81 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -400,6 +400,8 @@ def __init__( self.on_exit = on_exit + # 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 @@ -411,6 +413,9 @@ def __init__( self._create_impl() + # Now that we have an impl, set the on_change handler for commands + self.commands.on_change = self._impl.create_menus + for window in windows: self.windows.add(window) diff --git a/core/src/toga/command.py b/core/src/toga/command.py index f51f0e5e5b..15f4006122 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -197,13 +197,12 @@ def __init__( self.section = section if section else 0 self.order = order if order else 0 - orig_action = action self.action = wrapped_handler(self, action) self.factory = get_platform_factory() self._impl = self.factory.Command(interface=self) - self.enabled = enabled and orig_action is not None + self.enabled = enabled @property def key(self) -> tuple[(int, int, str)]: @@ -221,7 +220,7 @@ def enabled(self) -> bool: @enabled.setter def enabled(self, value: bool): - self._enabled = value + self._enabled = value and getattr(self.action, "_raw", True) is not None self._impl.set_enabled(value) @property @@ -315,6 +314,11 @@ def add(self, *commands: Command | Group): if self.on_change: self.on_change() + def clear(self): + self._commands = set() + if self.on_change: + self.on_change() + @property def app(self) -> App: return self._app diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py index 4445f359ed..c4424fc7b1 100644 --- a/core/tests/command/test_commandset.py +++ b/core/tests/command/test_commandset.py @@ -25,8 +25,8 @@ def test_create_with_values(): @pytest.mark.parametrize("change_handler", [(None), (Mock())]) -def test_add(app, change_handler): - """Commands can be added to a commandset""" +def test_add_clear(app, change_handler): + """Commands can be added and removed from a commandset""" # Put some commands into the app cmd_a = toga.Command(None, text="App command a") cmd_b = toga.Command(None, text="App command b", order=10) @@ -71,6 +71,20 @@ def test_add(app, change_handler): # App also knows about the command assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] + # Clear the command set + cs.clear() + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set no commands. + assert list(cs) == [] + + # App command set hasn't changed. + assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] + def test_ordering(parent_group_1, parent_group_2, child_group_1, child_group_2): """Ordering of groups, breaks and commands is preserved""" diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 1c9d38cb00..a471037968 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -6,6 +6,8 @@ from pytest import fixture, register_assert_rewrite, skip import toga +from toga.colors import GOLDENROD +from toga.style import Pack # Ideally, we'd register rewrites for "tests" and get all the submodules # recursively; however we've already imported "tests", so that raises a warning. @@ -50,6 +52,19 @@ def main_window(app): return app.main_window +@fixture +async def main_window_probe(app, main_window): + old_content = main_window.content + + # Put something in the content window so that we know it's an app test + main_window.content = toga.Box(style=Pack(background_color=GOLDENROD)) + + module = import_module("tests_backend.window") + yield getattr(module, "WindowProbe")(app, main_window) + + main_window.content = old_content + + # Controls the event loop used by pytest-asyncio. @fixture(scope="session") def event_loop(app): diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py new file mode 100644 index 0000000000..0310bbec7a --- /dev/null +++ b/testbed/tests/test_app.py @@ -0,0 +1,504 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE +from toga.style.pack import Pack + +from .test_window import window_probe + + +@pytest.fixture +def mock_app_exit(monkeypatch, app): + # We can't actually exit during a test, so monkeypatch the exit met""" + app_exit = Mock() + monkeypatch.setattr(toga.App, "exit", app_exit) + return app_exit + + +async def test_exit_on_close_main_window(app, main_window_probe, mock_app_exit): + """An app can be exited by closing the main window""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Close the main window + main_window_probe.close() + await main_window_probe.redraw("Main window close requested, but rejected") + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + main_window_probe.close() + await main_window_probe.redraw("Main window close requested, and accepted") + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() + + +async def test_main_window_toolbar(app, main_window, main_window_probe): + """A toolbar can be added to a main window""" + action = Mock() + + # A command with everything + group = toga.Group("Other") + cmd1 = toga.Command( + action, + "Full command", + icon=toga.Icon.DEFAULT_ICON, + tooltip="A full command definition", + shortcut=toga.Key.MOD_1 + "1", + group=group, + ) + # A command with everything + cmd2 = toga.Command( + action, + "No Tooltip", + icon=toga.Icon.DEFAULT_ICON, + shortcut=toga.Key.MOD_1 + "2", + ) + # A command without an icon + cmd3 = toga.Command( + action, + "No Icon", + tooltip="A command with no icon", + shortcut=toga.Key.MOD_1 + "3", + ) + + main_window.toolbar.add(cmd1, cmd2, cmd3) + + await main_window_probe.redraw("Main window has a toolbar") + assert main_window_probe.has_toolbar() + # Ordering is lexicographical for cmd 2 and 3. + main_window_probe.assert_toolbar_item( + 0, + label="Full command", + tooltip="A full command definition", + has_icon=True, + enabled=True, + ) + main_window_probe.assert_is_toolbar_separator(1) + main_window_probe.assert_toolbar_item( + 2, + label="No Icon", + tooltip="A command with no icon", + has_icon=False, + enabled=True, + ) + main_window_probe.assert_toolbar_item( + 3, + label="No Tooltip", + tooltip=None, + has_icon=True, + enabled=True, + ) + + # Press the first toolbar button + main_window_probe.press_toolbar_button(0) + await main_window_probe.redraw("Command 1 invoked") + action.assert_called_once_with(cmd1) + action.reset_mock() + + # Disable the first toolbar button + cmd1.enabled = False + await main_window_probe.redraw("Command 1 disabled") + main_window_probe.assert_toolbar_item( + 0, + label="Full command", + tooltip="A full command definition", + has_icon=True, + enabled=False, + ) + + # Re-enable the first toolbar button + cmd1.enabled = True + await main_window_probe.redraw("Command 1 re-enabled") + main_window_probe.assert_toolbar_item( + 0, + label="Full command", + tooltip="A full command definition", + has_icon=True, + enabled=True, + ) + + # Remove the toolbar + main_window.toolbar.clear() + await main_window_probe.redraw("Main window has no toolbar") + assert not main_window_probe.has_toolbar() + + +async def test_system_menus(app_probe): + """System-specific menus behave as expected""" + # Check that the system menus (which can be platform specific) exist. + app_probe.assert_system_menus() + + +async def test_menu_exit(app, app_probe, mock_app_exit): + """An app can be exited by using the menu item""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Close the main window + app_probe.activate_menu_exit() + await app_probe.redraw("Exit selected from menu, but rejected") + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + app_probe.activate_menu_exit() + await app_probe.redraw("Exit selected from menu, and accepted") + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() + + +async def test_menu_about(monkeypatch, app, app_probe): + """The about menu can be displayed""" + app_probe.activate_menu_about() + await app_probe.redraw("About dialog shown") + + app_probe.close_about_dialog() + await app_probe.redraw("About dialog destroyed") + + # Make the app definition minimal to verify the dialog still displays + monkeypatch.setattr(app, "_author", None) + monkeypatch.setattr(app, "_version", None) + + app_probe.activate_menu_about() + await app_probe.redraw("About dialog with no details shown") + + app_probe.close_about_dialog() + await app_probe.redraw("About dialog with no details destroyed") + + +async def test_menu_visit_homepage(monkeypatch, app, app_probe): + """The visit homepage menu item can be used""" + # We don't actually want to open a web browser; just check that the interface method + # was invoked. + visit_homepage = Mock() + monkeypatch.setattr(app, "visit_homepage", visit_homepage) + + app_probe.activate_menu_visit_homepage() + + # Browser opened + visit_homepage.assert_called_once() + + +async def test_menu_items(app, app_probe): + """Menu items can be created, disabled and invoked""" + action = Mock() + + # A command with everything + group = toga.Group("Other") + cmd1 = toga.Command( + action, + "Full command", + icon=toga.Icon.DEFAULT_ICON, + tooltip="A full command definition", + shortcut=toga.Key.MOD_1 + "1", + group=group, + ) + # A command with everything + cmd2 = toga.Command( + action, + "No Tooltip", + icon=toga.Icon.DEFAULT_ICON, + shortcut=toga.Key.MOD_1 + "2", + ) + # A command without an icon + cmd3 = toga.Command( + action, + "No Icon", + tooltip="A command with no icon", + shortcut=toga.Key.MOD_1 + "3", + ) + # Submenus inside the "other" group + subgroup1 = toga.Group("Submenu1", section=2, parent=group) + subgroup1_1 = toga.Group("Submenu1 menu1", parent=subgroup1) + subgroup2 = toga.Group("Submenu2", section=2, parent=group) + + # Items on submenu1 + # An item that is disabled by default + disabled_item = toga.Command(action, "Disabled", enabled=False, group=subgroup1) + # An item that has no action + no_action = toga.Command(None, "No Action", group=subgroup1) + # An item deep in a menu + deep_item = toga.Command(action, "Deep", group=subgroup1_1) + + # Items on submenu2 + cmd4 = toga.Command(action, "Jiggle", group=subgroup2) + + # Add all the commands + app.commands.add(cmd1, cmd2, cmd3, disabled_item, no_action, deep_item, cmd4) + + app_probe.redraw("App has custom menu items") + + app_probe.assert_menu_item( + ["Other", "Full command"], + enabled=True, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "Disabled"], + enabled=False, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "No Action"], + enabled=False, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "Submenu1 menu1", "Deep"], + enabled=True, + ) + + # Enabled the disabled items + disabled_item.enabled = True + no_action.enabled = True + await app_probe.redraw("Menu items enabled") + app_probe.assert_menu_item( + ["Other", "Submenu1", "Disabled"], + enabled=True, + ) + # Item has no action - it can't be enabled + app_probe.assert_menu_item( + ["Other", "Submenu1", "No Action"], + enabled=False, + ) + + disabled_item.enabled = False + no_action.enabled = False + await app_probe.redraw("Menu item disabled again") + app_probe.assert_menu_item( + ["Other", "Submenu1", "Disabled"], + enabled=False, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "No Action"], + enabled=False, + ) + + +async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + window1.show() + window2.show() + window3.show() + + app.current_window = window2 + + await app_probe.redraw("Extra windows added") + + app_probe.activate_menu_close_window() + assert window2 not in app.windows + + await app_probe.redraw("Window 2 closed") + + app_probe.activate_menu_close_all_windows() + + # Close all windows will attempt to close the main window as well. + # This would be an app exit, but we can't allow that; so, the only + # window that *actually* remains will be the main window. + mock_app_exit.assert_called_once_with() + assert window1 not in app.windows + assert window2 not in app.windows + assert window3 not in app.windows + + await app_probe.redraw("Extra windows closed") + + # Now that we've "closed" all the windows, we're in a state where there + # aren't any windows. Patch get_current_window to reflect this. + monkeypatch.setattr(app._impl, "get_current_window", Mock(return_value=None)) + app_probe.activate_menu_close_window() + await app_probe.redraw("No windows; Close Window is a no-op") + + app_probe.activate_menu_minimize() + await app_probe.redraw("No windows; Minimize is a no-op") + + finally: + if window1 in app.windows: + window1.close() + if window2 in app.windows: + window2.close() + if window3 in app.windows: + window3.close() + + +async def test_menu_minimize(app, app_probe): + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window1.show() + + window1_probe = window_probe(app, window1) + + app.current_window = window1 + await app_probe.redraw("Extra window added") + + app_probe.activate_menu_minimize() + + await window1_probe.wait_for_window("Extra window minimized", minimize=True) + assert window1_probe.is_minimized + finally: + window1.close() + + +async def test_beep(app): + """The machine can go Bing!""" + # This isn't a very good test. It ensures coverage, which verifies that the method + # can be invoked without raising an error, but there's no way to verify that the app + # actually made a noise. + app.beep() + + +async def test_current_window(app, app_probe): + """The current window can be retrieved.""" + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + window1.show() + window2.show() + window3.show() + + await app_probe.redraw("Extra windows added") + + app.current_window = window2 + await app_probe.redraw("Window 2 is current") + assert app.current_window == window2 + + app.current_window = window3 + await app_probe.redraw("Window 3 is current") + assert app.current_window == window3 + + # app_probe.platform tests? + finally: + window1.close() + window2.close() + window3.close() + + +async def test_full_screen(app, app_probe): + """Window can be made full screen""" + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + + window1.show() + window2.show() + await app_probe.redraw("Extra windows are visible") + + assert not app.is_full_screen + assert not app_probe.is_full_screen(window1) + assert not app_probe.is_full_screen(window2) + initial_content1_size = app_probe.content_size(window1) + initial_content2_size = app_probe.content_size(window2) + + # Make window 2 full screen via the app + app.set_full_screen(window2) + await app_probe.redraw("Second extra window is full screen") + assert app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert app_probe.is_full_screen(window2) + assert app_probe.content_size(window2)[0] > 1000 + assert app_probe.content_size(window2)[1] > 1000 + + # Make window 1 full screen via the app, window 2 no longer full screen + app.set_full_screen(window1) + # A longer delay to allow for genie animations + await app_probe.redraw("First extra window is full screen") + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 1000 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen + app.exit_full_screen() + await app_probe.redraw("No longer full screen", delay=0.1) + + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Go full screen again on window 1 + app.set_full_screen(window1) + # A longer delay to allow for genie animations + await app_probe.redraw("First extra window is full screen", delay=0.1) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 1000 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen by passing no windows + app.set_full_screen() + + await app_probe.redraw("App no longer full screen", delay=0.1) + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + finally: + window1.close() + window2.close() + + +async def test_show_hide_cursor(app, app_probe): + """The app cursor can be hidden and shown""" + app.hide_cursor() + await app_probe.redraw("Cursor is hidden") + assert not app_probe.is_cursor_visible + + # Hiding again can't make it more hidden + app.hide_cursor() + await app_probe.redraw("Cursor is still hidden") + assert not app_probe.is_cursor_visible + + # Show the cursor again + app.show_cursor() + await app_probe.redraw("Cursor is visible") + assert app_probe.is_cursor_visible + + # Hiding again can't make it more hidden + app.show_cursor() + await app_probe.redraw("Cursor is still visible") + assert app_probe.is_cursor_visible diff --git a/testbed/tests/test_window.py b/testbed/tests/test_window.py index fb2b8f436c..c526ea9d1b 100644 --- a/testbed/tests/test_window.py +++ b/testbed/tests/test_window.py @@ -31,11 +31,6 @@ async def second_window_probe(app, second_window): second_window.close() -@pytest.fixture -async def main_window_probe(app, main_window): - yield window_probe(app, main_window) - - async def test_title(main_window, main_window_probe): """The title of a window can be changed""" original_title = main_window.title @@ -222,6 +217,33 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob assert second_window not in app.windows + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 300), size=(400, 200))], + ) + async def test_secondary_window_toolbar(second_window, second_window_probe): + """A toolbar can be added to a secondary window""" + action = Mock() + + # A command with everything + group = toga.Group("Other") + cmd1 = toga.Command( + action, + "Full command", + icon=toga.Icon.DEFAULT_ICON, + tooltip="A full command definition", + shortcut=toga.Key.MOD_1 + "1", + group=group, + ) + + second_window.toolbar.add(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") + @pytest.mark.parametrize( "second_window_kwargs", [dict(title="Not Resizable", resizable=False, position=(200, 150))], diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index a887b7c988..edf4ec94dc 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -151,7 +151,7 @@ def get_terminal_size(*args, **kwargs): report_coverage=report_coverage, ) ) - app.add_background_task(lambda app, *kwargs: thread.start()) + thread.start() # Start the test app. app.main_loop() From d27307b250508063348c625d9ea6216e01cfd8f0 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 15 Aug 2023 13:27:50 +0800 Subject: [PATCH 12/66] 100% coverage of keys. --- cocoa/src/toga_cocoa/keys.py | 310 +++++++++++++++++++---------------- cocoa/tests_backend/app.py | 39 ++++- testbed/tests/test_keys.py | 41 +++++ 3 files changed, 248 insertions(+), 142 deletions(-) create mode 100644 testbed/tests/test_keys.py diff --git a/cocoa/src/toga_cocoa/keys.py b/cocoa/src/toga_cocoa/keys.py index b027dae328..a28bfe0ad8 100644 --- a/cocoa/src/toga_cocoa/keys.py +++ b/cocoa/src/toga_cocoa/keys.py @@ -9,115 +9,141 @@ NSEventModifierFlagShift, ) +# A map of Cocoa keycodes to Toga key values, when no Shift is pressed +TOGA_KEYS = { + 0: Key.A, + 1: Key.S, + 2: Key.D, + 3: Key.F, + 4: Key.H, + 5: Key.G, + 6: Key.Z, + 7: Key.X, + 8: Key.C, + 9: Key.V, + 11: Key.B, + 12: Key.Q, + 13: Key.W, + 14: Key.E, + 15: Key.R, + 16: Key.Y, + 17: Key.T, + 18: Key._1, + 19: Key._2, + 20: Key._3, + 21: Key._4, + 22: Key._6, + 23: Key._5, + 24: Key.PLUS, + 25: Key._9, + 26: Key._7, + 27: Key.MINUS, + 28: Key._8, + 29: Key._0, + 30: Key.CLOSE_BRACKET, + 31: Key.O, + 32: Key.U, + 33: Key.OPEN_BRACKET, + 34: Key.I, + 35: Key.P, + 36: Key.ENTER, + 37: Key.L, + 38: Key.J, + 39: Key.QUOTE, + 40: Key.K, + 41: Key.COLON, + 42: Key.BACKSLASH, + 43: Key.COMMA, + 44: Key.SLASH, + 45: Key.N, + 46: Key.M, + 47: Key.FULL_STOP, + 48: Key.TAB, + 49: Key.SPACE, + 50: Key.BACK_QUOTE, + 51: Key.BACKSPACE, + 53: Key.ESCAPE, + 65: Key.NUMPAD_DECIMAL_POINT, + 67: Key.NUMPAD_MULTIPLY, + 69: Key.NUMPAD_PLUS, + 71: Key.NUMPAD_CLEAR, + 75: Key.NUMPAD_DIVIDE, + 76: Key.NUMPAD_ENTER, + 78: Key.NUMPAD_MINUS, + 81: Key.NUMPAD_EQUAL, + 82: Key.NUMPAD_0, + 83: Key.NUMPAD_1, + 84: Key.NUMPAD_2, + 85: Key.NUMPAD_3, + 86: Key.NUMPAD_4, + 87: Key.NUMPAD_5, + 88: Key.NUMPAD_6, + 89: Key.NUMPAD_7, + 91: Key.NUMPAD_8, + 92: Key.NUMPAD_9, + # : Key.F4, + 96: Key.F5, + 97: Key.F7, + 98: Key.F5, + 99: Key.F3, + 100: Key.F8, + 101: Key.F9, + 109: Key.F9, + 115: Key.HOME, + 116: Key.PAGE_UP, + 117: Key.DELETE, + 119: Key.END, + 120: Key.F2, + 121: Key.PAGE_DOWN, + 122: Key.F1, + 123: Key.LEFT, + 124: Key.RIGHT, + 125: Key.DOWN, + 126: Key.UP, +} -def modified_key(key, shift=None): - def mod_fn(modifierFlags): - if modifierFlags & NSEventModifierFlagShift: - return shift - return key - - return mod_fn +# Keys that have a different Toga key when Shift is pressed. +TOGA_SHIFT_MODIFIED = { + Key._1: Key.EXCLAMATION, + Key._2: Key.AT, + Key._3: Key.HASH, + Key._4: Key.DOLLAR, + Key._6: Key.CARET, + Key._5: Key.PERCENT, + Key.PLUS: Key.EQUAL, + Key._9: Key.OPEN_PARENTHESIS, + Key._7: Key.AMPERSAND, + Key.MINUS: Key.UNDERSCORE, + Key._8: Key.ASTERISK, + Key._0: Key.CLOSE_PARENTHESIS, + Key.CLOSE_BRACKET: Key.CLOSE_BRACKET, + Key.OPEN_BRACKET: Key.OPEN_BRACKET, + Key.ENTER: Key.ENTER, + Key.QUOTE: Key.DOUBLE_QUOTE, + Key.COLON: Key.SEMICOLON, + Key.COMMA: Key.LESS_THAN, + Key.SLASH: Key.QUESTION, + Key.FULL_STOP: Key.GREATER_THAN, + Key.BACK_QUOTE: Key.TILDE, +} def toga_key(event): """Convert a Cocoa NSKeyEvent into a Toga event.""" - key = { - 0: Key.A, - 1: Key.S, - 2: Key.D, - 3: Key.F, - 4: Key.H, - 5: Key.G, - 6: Key.Z, - 7: Key.X, - 8: Key.C, - 9: Key.V, - 11: Key.B, - 12: Key.Q, - 13: Key.W, - 14: Key.E, - 15: Key.R, - 16: Key.Y, - 17: Key.T, - 18: modified_key(Key._1, shift=Key.EXCLAMATION)(event.modifierFlags), - 19: modified_key(Key._2, shift=Key.AT)(event.modifierFlags), - 20: modified_key(Key._3, shift=Key.HASH)(event.modifierFlags), - 21: modified_key(Key._4, shift=Key.DOLLAR)(event.modifierFlags), - 22: modified_key(Key._6, shift=Key.CARET)(event.modifierFlags), - 23: modified_key(Key._5, shift=Key.PERCENT)(event.modifierFlags), - 24: modified_key(Key.PLUS, shift=Key.EQUAL)(event.modifierFlags), - 25: modified_key(Key._9, shift=Key.OPEN_PARENTHESIS)(event.modifierFlags), - 26: modified_key(Key._7, shift=Key.AMPERSAND)(event.modifierFlags), - 27: modified_key(Key.MINUS, shift=Key.UNDERSCORE)(event.modifierFlags), - 28: modified_key(Key._8, shift=Key.ASTERISK)(event.modifierFlags), - 29: modified_key(Key._0, shift=Key.CLOSE_PARENTHESIS)(event.modifierFlags), - 30: Key.CLOSE_BRACKET, - 31: Key.O, - 32: Key.U, - 33: Key.OPEN_BRACKET, - 34: Key.I, - 35: Key.P, - 36: Key.ENTER, - 37: Key.L, - 38: Key.J, - 39: modified_key(Key.QUOTE, shift=Key.DOUBLE_QUOTE)(event.modifierFlags), - 40: Key.K, - 41: modified_key(Key.COLON, shift=Key.SEMICOLON)(event.modifierFlags), - 42: Key.BACKSLASH, - 43: modified_key(Key.COMMA, shift=Key.LESS_THAN)(event.modifierFlags), - 44: modified_key(Key.SLASH, shift=Key.QUESTION)(event.modifierFlags), - 45: Key.N, - 46: Key.M, - 47: modified_key(Key.FULL_STOP, shift=Key.GREATER_THAN)(event.modifierFlags), - 48: Key.TAB, - 49: Key.SPACE, - 50: modified_key(Key.BACK_QUOTE, shift=Key.TILDE)(event.modifierFlags), - 51: Key.BACKSPACE, - 53: Key.ESCAPE, - 65: Key.NUMPAD_DECIMAL_POINT, - 67: Key.NUMPAD_MULTIPLY, - 69: Key.NUMPAD_PLUS, - 71: Key.NUMPAD_CLEAR, - 75: Key.NUMPAD_DIVIDE, - 76: Key.NUMPAD_ENTER, - 78: Key.NUMPAD_MINUS, - 81: Key.NUMPAD_EQUAL, - 82: Key.NUMPAD_0, - 83: Key.NUMPAD_1, - 84: Key.NUMPAD_2, - 85: Key.NUMPAD_3, - 86: Key.NUMPAD_4, - 87: Key.NUMPAD_5, - 88: Key.NUMPAD_6, - 89: Key.NUMPAD_7, - 91: Key.NUMPAD_8, - 92: Key.NUMPAD_9, - # : Key.F4, - 96: Key.F5, - 97: Key.F7, - 98: Key.F5, - 99: Key.F3, - 100: Key.F8, - 101: Key.F9, - 109: Key.F9, - 115: Key.HOME, - 116: Key.PAGE_UP, - 117: Key.DELETE, - 119: Key.END, - 120: Key.F2, - 121: Key.PAGE_DOWN, - 122: Key.F1, - 123: Key.LEFT, - 124: Key.RIGHT, - 125: Key.DOWN, - 126: Key.UP, - }.get(event.keyCode, None) + natural_key = TOGA_KEYS.get(event.keyCode, None) + if event.modifierFlags & NSEventModifierFlagShift: + try: + key = TOGA_SHIFT_MODIFIED[natural_key] + except KeyError: + key = natural_key + else: + key = natural_key modifiers = set() - if event.modifierFlags & NSEventModifierFlagCapsLock: - modifiers.add(Key.CAPSLOCK) - if event.modifierFlags & NSEventModifierFlagShift: + # Only apply a shift modifier for the a/A case. + # keys like ! that inherently need shift don't return as modified. + if event.modifierFlags & NSEventModifierFlagShift and key == natural_key: modifiers.add(Key.SHIFT) if event.modifierFlags & NSEventModifierFlagCommand: modifiers.add(Key.MOD_1) @@ -130,39 +156,39 @@ def toga_key(event): COCOA_KEY_CODES = { - Key.ESCAPE: "%c" % 0x001B, - Key.TAB: "%c" % 0x0009, - Key.BACKSPACE: "%c" % 0x0008, - Key.ENTER: "%c" % 0x000D, - Key.F1: "", # TODO - Key.F2: "", # TODO - Key.F3: "", # TODO - Key.F4: "", # TODO - Key.F5: "", # TODO - Key.F6: "", # TODO - Key.F7: "", # TODO - Key.F8: "", # TODO - Key.F9: "", # TODO - Key.F10: "", # TODO - Key.F11: "", # TODO - Key.F12: "", # TODO - Key.F13: "", # TODO - Key.F14: "", # TODO - Key.F15: "", # TODO - Key.F16: "", # TODO - Key.F17: "", # TODO - Key.F18: "", # TODO - Key.F19: "", # TODO + Key.ESCAPE: chr(0x001B), + Key.TAB: chr(0x0009), + Key.BACKSPACE: chr(0x0008), + Key.ENTER: chr(0x000D), + Key.F1: chr(0xF704), + Key.F2: chr(0xF705), + Key.F3: chr(0xF706), + Key.F4: chr(0xF707), + Key.F5: chr(0xF708), + Key.F6: chr(0xF709), + Key.F7: chr(0xF70A), + Key.F8: chr(0xF70B), + Key.F9: chr(0xF70C), + Key.F10: chr(0xF70D), + Key.F11: chr(0xF70E), + Key.F12: chr(0xF70F), + Key.F13: chr(0xF710), + Key.F14: chr(0xF711), + Key.F15: chr(0xF712), + Key.F16: chr(0xF713), + Key.F17: chr(0xF714), + Key.F18: chr(0xF715), + Key.F19: chr(0xF716), Key.EJECT: "", # TODO - Key.HOME: "%c" % 0x2196, - Key.END: "%c" % 0x2198, - Key.DELETE: "%c" % 0x007F, - Key.PAGE_UP: "%c" % 0x21DE, - Key.PAGE_DOWN: "%c" % 0x21DF, - Key.UP: "%c" % 0x001E, - Key.DOWN: "%c" % 0x001F, - Key.LEFT: "%c" % 0x001C, - Key.RIGHT: "%c" % 0x001D, + Key.HOME: chr(0x2196), + Key.END: chr(0x2198), + Key.DELETE: chr(0x007F), + Key.PAGE_UP: chr(0x21DE), + Key.PAGE_DOWN: chr(0x21DF), + Key.UP: chr(0x001E), + Key.DOWN: chr(0x001F), + Key.LEFT: chr(0x001C), + Key.RIGHT: chr(0x001D), Key.NUMPAD_0: "0", Key.NUMPAD_1: "1", Key.NUMPAD_2: "2", @@ -174,13 +200,13 @@ def toga_key(event): Key.NUMPAD_8: "8", Key.NUMPAD_9: "9", Key.NUMPAD_CLEAR: "", # TODO - Key.NUMPAD_DECIMAL_POINT: "", # TODO - Key.NUMPAD_DIVIDE: "", # TODO - Key.NUMPAD_ENTER: "", # TODO - Key.NUMPAD_EQUAL: "", # TODO - Key.NUMPAD_MINUS: "", # TODO - Key.NUMPAD_MULTIPLY: "", # TODO - Key.NUMPAD_PLUS: "", # TODO + Key.NUMPAD_DECIMAL_POINT: ".", + Key.NUMPAD_DIVIDE: "/", + Key.NUMPAD_ENTER: chr(0x000D), + Key.NUMPAD_EQUAL: "=", + Key.NUMPAD_MINUS: "-", + Key.NUMPAD_MULTIPLY: "*", + Key.NUMPAD_PLUS: "+", } COCOA_MODIFIERS = { @@ -213,4 +239,8 @@ def cocoa_key(shortcut): key = key.replace(mod.value, "") modifiers |= mask + # If the remaining key string is upper case, add a shift modifier. + if key.isupper(): + modifiers |= NSEventModifierFlagShift + return key, modifiers diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 26616c510b..31ff9ae92f 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -1,9 +1,15 @@ from pathlib import Path -from rubicon.objc import ObjCClass, send_message +from rubicon.objc import NSPoint, ObjCClass, send_message from rubicon.objc.runtime import objc_id -from toga_cocoa.libs import NSApplication +from toga_cocoa.keys import cocoa_key, toga_key +from toga_cocoa.libs import ( + NSApplication, + NSEvent, + NSEventModifierFlagShift, + NSEventType, +) from .probe import BaseProbe @@ -126,3 +132,32 @@ def activate_menu_minimize(self): def assert_menu_item(self, path, enabled): item = self._menu_item(path) assert item.isEnabled() == enabled + + def keystroke(self, combination): + key, modifiers = cocoa_key(combination) + key_code = { + "a": 0, + "A": 0, + "1": 18, + "!": 18, + chr(0xF708): 96, # F5 + chr(0x2196): 115, # Home + }[key] + + # Add the shift modifier to disambiguate 1 from ! + if key in {"!"}: + modifiers |= NSEventModifierFlagShift + + event = NSEvent.keyEventWithType( + NSEventType.KeyDown, + location=NSPoint(0, 0), # key presses don't have a location. + modifierFlags=modifiers, + timestamp=0, + windowNumber=self.app.main_window._impl.native.windowNumber, + context=None, + characters="?", + charactersIgnoringModifiers="?", + isARepeat=False, + keyCode=key_code, + ) + return toga_key(event) diff --git a/testbed/tests/test_keys.py b/testbed/tests/test_keys.py new file mode 100644 index 0000000000..9b1f3ace4b --- /dev/null +++ b/testbed/tests/test_keys.py @@ -0,0 +1,41 @@ +import pytest + +from toga.keys import Key + + +@pytest.mark.parametrize( + "key_combo, key_data", + [ + # lower case + ("a", {"key": Key.A, "modifiers": set()}), + # upper case + ("A", {"key": Key.A, "modifiers": {Key.SHIFT}}), + # single modifier + (Key.MOD_1 + "a", {"key": Key.A, "modifiers": {Key.MOD_1}}), + (Key.MOD_2 + "a", {"key": Key.A, "modifiers": {Key.MOD_2}}), + (Key.MOD_3 + "a", {"key": Key.A, "modifiers": {Key.MOD_3}}), + # modifier combinations + ( + Key.MOD_1 + Key.MOD_2 + "a", + {"key": Key.A, "modifiers": {Key.MOD_1, Key.MOD_2}}, + ), + ( + Key.MOD_2 + Key.MOD_1 + "a", + {"key": Key.A, "modifiers": {Key.MOD_1, Key.MOD_2}}, + ), + ( + Key.MOD_1 + Key.MOD_2 + Key.MOD_3 + "A", + {"key": Key.A, "modifiers": {Key.MOD_1, Key.MOD_2, Key.MOD_3, Key.SHIFT}}, + ), + # A key which is shift modified + ("1", {"key": Key._1, "modifiers": set()}), + ("!", {"key": Key.EXCLAMATION, "modifiers": set()}), + # Special keys + (Key.F5, {"key": Key.F5, "modifiers": set()}), + (Key.HOME, {"key": Key.HOME, "modifiers": set()}), + (Key.HOME + Key.MOD_1, {"key": Key.HOME, "modifiers": {Key.MOD_1}}), + ], +) +def test_key_combinations(app_probe, key_combo, key_data): + """Key combinations can be round tripped""" + assert app_probe.keystroke(key_combo) == key_data From d505c950879d21d2ec81ceef0f1ffd930ec32106 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 16 Aug 2023 10:48:46 +0800 Subject: [PATCH 13/66] Insert a pause on app exit to make sure Briefcase gets all the app logs. --- testbed/tests/testbed.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index fd8da6b187..edf4ec94dc 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -2,6 +2,7 @@ import os import sys import tempfile +import time import traceback from functools import partial from pathlib import Path @@ -75,6 +76,9 @@ def run_tests(app, cov, args, report_coverage, run_slow): traceback.print_exc() app.returncode = 1 finally: + print(f">>>>>>>>>> EXIT {app.returncode} <<<<<<<<<<") + # Add a short pause to make sure any log tailing gets a chance to flush + time.sleep(0.5) app.add_background_task(lambda app, **kwargs: app.exit()) @@ -147,14 +151,7 @@ def get_terminal_size(*args, **kwargs): report_coverage=report_coverage, ) ) - app.add_background_task(lambda app, *kwargs: thread.start()) - - # Add an on_exit handler that will terminate the test suite. - def exit_suite(app, **kwargs): - print(f">>>>>>>>>> EXIT {app.returncode} <<<<<<<<<<") - return True - - app.on_exit = exit_suite + thread.start() # Start the test app. app.main_loop() From fedd4fec16a0fbf7e78c5f5b71ccfc27f332b03b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 16 Aug 2023 17:38:27 +0800 Subject: [PATCH 14/66] GTK app tests passing. --- cocoa/src/toga_cocoa/app.py | 4 +- gtk/src/toga_gtk/app.py | 52 ++++++++++++++------ gtk/src/toga_gtk/window.py | 42 +++++++++------- gtk/tests_backend/app.py | 97 ++++++++++++++++++++++++++++++++++++- gtk/tests_backend/window.py | 21 ++++++++ testbed/tests/test_app.py | 29 ++++++++--- 6 files changed, 202 insertions(+), 43 deletions(-) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 893ca2b038..8e1b0b4c4a 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -190,7 +190,7 @@ def _create_app_commands(self): ), # Quit should always be the last item, in a section on its own toga.Command( - self._menu_exit, + self._menu_quit, "Quit " + formal_name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, @@ -300,7 +300,7 @@ def _create_app_commands(self): def _menu_about(self, app, **kwargs): self.interface.about() - def _menu_exit(self, app, **kwargs): + def _menu_quit(self, app, **kwargs): self.interface.on_exit(None) def _menu_close_window(self, app, **kwargs): diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index cf10209563..80a55fe5b2 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -68,6 +68,7 @@ def create(self): application_id=self.interface.app_id, flags=Gio.ApplicationFlags.FLAGS_NONE, ) + self.native_about_dialog = None # Connect the GTK signal that will cause app startup to occur self.native.connect("startup", self.gtk_startup) @@ -79,14 +80,19 @@ def gtk_startup(self, data=None): # Set up the default commands for the interface. self.interface.commands.add( Command( - lambda _: self.interface.about(), + self._menu_about, "About " + self.interface.name, group=toga.Group.HELP, ), + Command( + self._menu_visit_homepage, + "Visit homepage", + 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( - lambda _: self.interface.on_exit(None), + self._menu_quit, "Quit " + self.interface.name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, @@ -125,6 +131,15 @@ def _create_app_commands(self): def gtk_activate(self, data=None): pass + def _menu_about(self, app, **kwargs): + self.interface.about() + + def _menu_quit(self, app, **kwargs): + self.interface.on_exit(None) + + def _menu_visit_homepage(self, app, **kwargs): + self.interface.visit_homepage() + def create_menus(self): # Only create the menu if the menu item index has been created. self._menu_items = {} @@ -199,27 +214,31 @@ def set_main_window(self, window): pass def show_about_dialog(self): - about = Gtk.AboutDialog() + self.native_about_dialog = Gtk.AboutDialog() + self.native_about_dialog.set_modal(True) icon_impl = toga_App.app.icon._impl - about.set_logo(icon_impl.native_72.get_pixbuf()) + self.native_about_dialog.set_logo(icon_impl.native_72.get_pixbuf()) - if self.interface.name is not None: - about.set_program_name(self.interface.name) + self.native_about_dialog.set_program_name(self.interface.name) if self.interface.version is not None: - about.set_version(self.interface.version) + self.native_about_dialog.set_version(self.interface.version) if self.interface.author is not None: - about.set_authors([self.interface.author]) + self.native_about_dialog.set_authors([self.interface.author]) if self.interface.description is not None: - about.set_comments(self.interface.description) + self.native_about_dialog.set_comments(self.interface.description) if self.interface.home_page is not None: - about.set_website(self.interface.home_page) + self.native_about_dialog.set_website(self.interface.home_page) + + self.native_about_dialog.show() + self.native_about_dialog.connect("close", self._close_about) - about.run() - about.destroy() + def _close_about(self, dialog): + self.native_about_dialog.destroy() + self.native_about_dialog = None def beep(self): - Gdk.gdk_beep() + Gdk.beep() def exit(self): self.native.quit() @@ -234,8 +253,11 @@ def set_current_window(self, window): window._impl.native.present() def enter_full_screen(self, windows): - for window in windows: - window._impl.set_full_screen(True) + for window in self.interface.windows: + if window in windows: + window._impl.set_full_screen(True) + else: + window._impl.set_full_screen(False) def exit_full_screen(self, windows): for window in windows: diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 208ee63ffe..afc10ae99d 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -33,18 +33,22 @@ def __init__(self, interface, title, position, size): # Window Decorator when resizable == False self.native.set_resizable(self.interface.resizable) - self.toolbar_native = None - self.toolbar_items = None - # The GTK window's content is the layout; any user content is placed # into the container, which is the bottom widget in the layout. The # toolbar (if required) will be added at the top of the layout. - # + self.layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.native_toolbar = Gtk.Toolbar() + self.native_toolbar.set_style(Gtk.ToolbarStyle.BOTH) + self.native_toolbar.set_visible(False) + self.toolbar_items = {} + 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. - self.layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.container = TogaContainer() self.layout.pack_end(self.container, expand=True, fill=True, padding=0) + self.native.add(self.layout) def get_title(self): @@ -57,18 +61,18 @@ def set_app(self, app): app.native.add_window(self.native) def create_toolbar(self): - if self.toolbar_items is None: - self.toolbar_native = Gtk.Toolbar() - self.toolbar_items = {} - self.layout.pack_start( - self.toolbar_native, expand=False, fill=False, padding=0 - ) - else: - for cmd, item_impl in self.toolbar_items.items(): - self.toolbar_native.remove(item_impl) + # Remove any pre-existing toolbar content + if self.toolbar_items: + self.native_toolbar.set_visible(False) + for cmd, item_impl in self.toolbar_items.items(): + self.native_toolbar.remove(item_impl) + try: cmd._impl.native.remove(item_impl) + except AttributeError: + # Breaks don't have _impls, so there's no native to clean up + pass - self.toolbar_native.set_style(Gtk.ToolbarStyle.BOTH) + # Create the new toolbar items for cmd in self.interface.toolbar: if cmd == GROUP_BREAK: item_impl = Gtk.SeparatorToolItem() @@ -81,11 +85,15 @@ def create_toolbar(self): if cmd.icon: item_impl.set_icon_widget(cmd.icon._impl.native_32) item_impl.set_label(cmd.text) - item_impl.set_tooltip_text(cmd.tooltip) + if cmd.tooltip: + item_impl.set_tooltip_text(cmd.tooltip) item_impl.connect("clicked", wrapped_handler(cmd, cmd.action)) cmd._impl.native.append(item_impl) self.toolbar_items[cmd] = item_impl - self.toolbar_native.insert(item_impl, -1) + self.native_toolbar.insert(item_impl, -1) + if self.toolbar_items: + self.native_toolbar.set_visible(True) + self.native_toolbar.show_all() def set_content(self, widget): # Set the new widget to be the container's content diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 72d677d0c7..2472519508 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -1,6 +1,9 @@ from pathlib import Path -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.keys import gtk_accel, toga_key +from toga_gtk.libs import Gdk, Gtk from .probe import BaseProbe @@ -26,3 +29,95 @@ def cache_path(self): @property def logs_path(self): return Path.home() / ".local" / "state" / "testbed" / "log" + + @property + def is_cursor_visible(self): + pytest.skip("Cursor visibility not implemented on GTK") + + def is_full_screen(self, window): + return bool( + window._impl.native.get_window().get_state() & Gdk.WindowState.FULLSCREEN + ) + + def content_size(self, window): + content_allocation = window._impl.container.get_allocation() + return (content_allocation.width, content_allocation.height) + + def _menu_item(self, path): + main_menu = self.app._impl.native.get_menubar() + menu = main_menu + orig_path = path.copy() + try: + while True: + label, path = path[0], path[1:] + items = {} + for index in range(menu.get_n_items()): + section = menu.get_item_link(index, "section") + if section: + for section_index in range(section.get_n_items()): + items[ + section.get_item_attribute_value( + section_index, "label" + ).get_string() + ] = (section, section_index) + else: + items[ + menu.get_item_attribute_value(index, "label").get_string() + ] = (menu, index) + + if label == "*": + item = items[self.app.name] + else: + item = items[label] + + menu = item[0].get_item_link(item[1], "submenu") + except IndexError: + pass + except AttributeError: + raise AssertionError(f"Menu {' > '.join(orig_path)} not found") + + action_name = item[0].get_item_attribute_value(item[1], "action").get_string() + cmd_id = action_name.split(".")[1] + action = self.app._impl.native.lookup_action(cmd_id) + return action + + def _activate_menu_item(self, path): + item = self._menu_item(path) + item.emit("activate", None) + + def activate_menu_exit(self): + self._activate_menu_item(["*", "Quit Toga Testbed"]) + + def activate_menu_about(self): + self._activate_menu_item(["Help", "About Toga Testbed"]) + + def close_about_dialog(self): + self.app._impl.native_about_dialog.close() + + def activate_menu_visit_homepage(self): + self._activate_menu_item(["Help", "Visit homepage"]) + + def assert_system_menus(self): + self.assert_menu_item(["*", "Preferences"], enabled=False) + self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) + + self.assert_menu_item(["Help", "About Toga Testbed"], enabled=True) + self.assert_menu_item(["Help", "Visit homepage"], enabled=True) + + def activate_menu_close_window(self): + pytest.xfail("GTK doesn't have a window management menu items") + + def activate_menu_close_all_windows(self): + pytest.xfail("GTK doesn't have a window management menu items") + + def activate_menu_minimize(self): + pytest.xfail("GTK doesn't have a window management menu items") + + def assert_menu_item(self, path, enabled): + item = self._menu_item(path) + assert item.get_enabled() == enabled + + def keystroke(self, combination): + accel = gtk_accel(combination) + + return toga_key(accel) diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 44fc078214..b58df20b6b 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -236,3 +236,24 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): f"({'OPEN' if result else 'CANCEL'}) dismissed" ), ) + + def has_toolbar(self): + return self.impl.native_toolbar.get_n_items() > 0 + + def assert_is_toolbar_separator(self, index): + item = self.impl.native_toolbar.get_nth_item(index) + assert isinstance(item, Gtk.SeparatorToolItem) + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self.impl.native_toolbar.get_nth_item(index) + assert item.get_label() == label + # FIXME: get_tooltip_text() doesn't work. The tooltip can be set, but the + # API to return the value just doesn't work. If it is ever fixed, this + # is the test for it: + # assert (None if item.get_tooltip_text() is None else item.get_tooltip_text()) == tooltip + assert (item.get_icon_widget() is not None) == has_icon + assert item.get_sensitive() == enabled + + def press_toolbar_button(self, index): + item = self.impl.native_toolbar.get_nth_item(index) + item.emit("clicked") diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 0310bbec7a..2cf96965ba 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -376,6 +376,9 @@ async def test_current_window(app, app_probe): window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + window2_probe = window_probe(app, window2) + window3_probe = window_probe(app, window2) + window1.show() window2.show() window3.show() @@ -383,11 +386,11 @@ async def test_current_window(app, app_probe): await app_probe.redraw("Extra windows added") app.current_window = window2 - await app_probe.redraw("Window 2 is current") + await window2_probe.wait_for_window("Window 2 is current") assert app.current_window == window2 app.current_window = window3 - await app_probe.redraw("Window 3 is current") + await window3_probe.wait_for_window("Window 3 is current") assert app.current_window == window3 # app_probe.platform tests? @@ -404,6 +407,8 @@ async def test_full_screen(app, app_probe): window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window1_probe = window_probe(app, window1) + window2_probe = window_probe(app, window2) window1.show() window2.show() @@ -417,7 +422,10 @@ async def test_full_screen(app, app_probe): # Make window 2 full screen via the app app.set_full_screen(window2) - await app_probe.redraw("Second extra window is full screen") + await window2_probe.wait_for_window( + "Second extra window is full screen", + full_screen=True, + ) assert app.is_full_screen assert not app_probe.is_full_screen(window1) @@ -429,8 +437,10 @@ async def test_full_screen(app, app_probe): # Make window 1 full screen via the app, window 2 no longer full screen app.set_full_screen(window1) - # A longer delay to allow for genie animations - await app_probe.redraw("First extra window is full screen") + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) assert app.is_full_screen assert app_probe.is_full_screen(window1) @@ -442,7 +452,7 @@ async def test_full_screen(app, app_probe): # Exit full screen app.exit_full_screen() - await app_probe.redraw("No longer full screen", delay=0.1) + await window1_probe.wait_for_window("No longer full screen", full_screen=True) assert not app.is_full_screen @@ -455,7 +465,10 @@ async def test_full_screen(app, app_probe): # Go full screen again on window 1 app.set_full_screen(window1) # A longer delay to allow for genie animations - await app_probe.redraw("First extra window is full screen", delay=0.1) + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) assert app.is_full_screen assert app_probe.is_full_screen(window1) @@ -468,7 +481,7 @@ async def test_full_screen(app, app_probe): # Exit full screen by passing no windows app.set_full_screen() - await app_probe.redraw("App no longer full screen", delay=0.1) + await window1_probe.wait_for_window("No longer full screen", full_screen=True) assert not app.is_full_screen assert not app_probe.is_full_screen(window1) From 6c8877c6feb4a348cfb2c7c680961d7287f44a3b Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Wed, 16 Aug 2023 22:10:34 +0200 Subject: [PATCH 15/66] hack around segfault for Gtk WebView tests --- testbed/tests/widgets/test_webview.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index f6b28f1a03..19de453d50 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -1,4 +1,5 @@ import asyncio +import gc from asyncio import wait_for from contextlib import nullcontext from time import time @@ -103,7 +104,14 @@ async def widget(on_load): else: raise - return widget + yield widget + + if toga.platform.current_platform == "linux": + # On Gtk, ensure that the WebView is garbage collection before the next test + # case. This prevents a segfault at GC time likely coming from the test suite + # running in a thread and Gtk WebViews sharing resources between instances. + del widget + gc.collect() async def test_set_url(widget, probe, on_load): From 596149f0ec39f78d6ff510541786fc3229b9e1cd Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Wed, 16 Aug 2023 22:10:34 +0200 Subject: [PATCH 16/66] Key tests at 100% on GTK. --- cocoa/src/toga_cocoa/keys.py | 2 ++ gtk/src/toga_gtk/keys.py | 7 +++++-- gtk/tests_backend/app.py | 35 ++++++++++++++++++++++++++++++++++- testbed/tests/test_keys.py | 1 + 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/keys.py b/cocoa/src/toga_cocoa/keys.py index a28bfe0ad8..e386d9ca30 100644 --- a/cocoa/src/toga_cocoa/keys.py +++ b/cocoa/src/toga_cocoa/keys.py @@ -151,6 +151,8 @@ def toga_key(event): modifiers.add(Key.MOD_2) if event.modifierFlags & NSEventModifierFlagControl: modifiers.add(Key.MOD_3) + if event.modifierFlags & NSEventModifierFlagCapsLock: + modifiers.add(Key.CAPSLOCK) return {"key": key, "modifiers": modifiers} diff --git a/gtk/src/toga_gtk/keys.py b/gtk/src/toga_gtk/keys.py index c9628379f8..463a229b6c 100644 --- a/gtk/src/toga_gtk/keys.py +++ b/gtk/src/toga_gtk/keys.py @@ -217,7 +217,6 @@ GTK_MODIFIER_CODES = { Key.CAPSLOCK: "", Key.SHIFT: "", - # TODO: Confirm the mapping of Control, Meta and Hyper are correct. Key.MOD_1: "", Key.MOD_2: "", Key.MOD_3: "", @@ -231,7 +230,6 @@ def toga_key(event): modifiers = set() - # TODO: Confirm the mapping of Control, Meta and Hyper are correct. if event.state & Gdk.ModifierType.LOCK_MASK: modifiers.add(Key.CAPSLOCK) if event.state & Gdk.ModifierType.SHIFT_MASK: @@ -264,6 +262,11 @@ def gtk_accel(shortcut): accel = accel.replace(key.value, "") modifiers.append(code) + # If the accelerator text is upper case, add a shift modifier. + if accel.isalpha() and accel.isupper(): + accel = accel.lower() + modifiers.append("") + # Find the canonical definition of the remaining key. for key, code in GTK_KEY_CODES.items(): if key.value == accel: diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 2472519508..ee7f4af907 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -119,5 +119,38 @@ def assert_menu_item(self, path, enabled): def keystroke(self, combination): accel = gtk_accel(combination) + state = 0 + + if "" in accel: + state |= Gdk.ModifierType.CONTROL_MASK + accel = accel.replace("", "") + if "" in accel: + state |= Gdk.ModifierType.META_MASK + accel = accel.replace("", "") + if "" in accel: + state |= Gdk.ModifierType.HYPER_MASK + accel = accel.replace("", "") + if "" in accel: + state |= Gdk.ModifierType.LOCK_MASK + accel = accel.replace("", "") + if "" in accel: + state |= Gdk.ModifierType.SHIFT_MASK + accel = accel.replace("", "") + + keyval = getattr( + Gdk, + f"KEY_{accel}", + { + "!": Gdk.KEY_exclam, + "": Gdk.KEY_Home, + "F5": Gdk.KEY_F5, + }.get(accel, None), + ) + + event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) + event.keyval = keyval + event.length = 1 + event.is_modifier = state != 0 + event.state = state - return toga_key(accel) + return toga_key(event) diff --git a/testbed/tests/test_keys.py b/testbed/tests/test_keys.py index 9b1f3ace4b..a453c78ca3 100644 --- a/testbed/tests/test_keys.py +++ b/testbed/tests/test_keys.py @@ -14,6 +14,7 @@ (Key.MOD_1 + "a", {"key": Key.A, "modifiers": {Key.MOD_1}}), (Key.MOD_2 + "a", {"key": Key.A, "modifiers": {Key.MOD_2}}), (Key.MOD_3 + "a", {"key": Key.A, "modifiers": {Key.MOD_3}}), + (Key.CAPSLOCK + "a", {"key": Key.A, "modifiers": {Key.CAPSLOCK}}), # modifier combinations ( Key.MOD_1 + Key.MOD_2 + "a", From 8140de2d66d256f98685f98724437b2f68f74550 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 17 Aug 2023 08:53:40 +0800 Subject: [PATCH 17/66] Tweaks to get 100% coverage on GTK. --- cocoa/tests_backend/window.py | 2 +- gtk/src/toga_gtk/app.py | 23 +++++------------------ gtk/src/toga_gtk/documents.py | 2 +- gtk/src/toga_gtk/keys.py | 29 +++++++++++++---------------- gtk/src/toga_gtk/window.py | 14 ++++++++++---- gtk/tests_backend/app.py | 6 +++--- gtk/tests_backend/window.py | 3 ++- testbed/tests/test_app.py | 22 ++++++++++++++++++++-- 8 files changed, 55 insertions(+), 46 deletions(-) diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index e40c6c6158..e6256d561e 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -253,7 +253,7 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): def has_toolbar(self): return self.native.toolbar is not None - def assert_is_toolbar_separator(self, index): + def assert_is_toolbar_separator(self, index, section=False): item = self.native.toolbar.items[index] assert str(item.itemIdentifier).startswith("ToolbarSeparator-") diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 80a55fe5b2..b2c1d653a6 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -25,10 +25,8 @@ def _handler(action, data): class MainWindow(Window): - _IMPL_CLASS = Gtk.ApplicationWindow - def create(self): - super().create() + self.native = Gtk.ApplicationWindow() self.native.set_role("MainWindow") icon_impl = toga_App.app.icon._impl self.native.set_icon(icon_impl.native_72.get_pixbuf()) @@ -84,11 +82,6 @@ def gtk_startup(self, data=None): "About " + self.interface.name, group=toga.Group.HELP, ), - Command( - self._menu_visit_homepage, - "Visit homepage", - 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( @@ -137,9 +130,6 @@ def _menu_about(self, app, **kwargs): def _menu_quit(self, app, **kwargs): self.interface.on_exit(None) - def _menu_visit_homepage(self, app, **kwargs): - self.interface.visit_homepage() - def create_menus(self): # Only create the menu if the menu item index has been created. self._menu_items = {} @@ -162,8 +152,7 @@ def create_menus(self): cmd_id = "command-%s" % id(cmd) action = Gio.SimpleAction.new(cmd_id, None) - if cmd.action: - action.connect("activate", gtk_menu_item_activate(cmd)) + action.connect("activate", gtk_menu_item_activate(cmd)) cmd._impl.native.append(action) cmd._impl.set_enabled(cmd.enabled) @@ -240,12 +229,10 @@ def _close_about(self, dialog): def beep(self): Gdk.beep() - def exit(self): + # We can't call this under test conditions, because it would kill the test harness + def exit(self): # pragma: no cover self.native.quit() - def set_on_exit(self, value): - pass - def get_current_window(self): return self.native.get_active_window()._impl @@ -270,7 +257,7 @@ def hide_cursor(self): self.interface.factory.not_implemented("App.hide_cursor()") -class DocumentApp(App): +class DocumentApp(App): # pragma: no cover def _create_app_commands(self): self.interface.commands.add( toga.Command( diff --git a/gtk/src/toga_gtk/documents.py b/gtk/src/toga_gtk/documents.py index a42d33f3fa..9000fc5ce5 100644 --- a/gtk/src/toga_gtk/documents.py +++ b/gtk/src/toga_gtk/documents.py @@ -1,4 +1,4 @@ -class Document: +class Document: # pragma: no cover # GTK has 1-1 correspondence between document and app instances. SINGLE_DOCUMENT_APP = True diff --git a/gtk/src/toga_gtk/keys.py b/gtk/src/toga_gtk/keys.py index 463a229b6c..9d48e4da9b 100644 --- a/gtk/src/toga_gtk/keys.py +++ b/gtk/src/toga_gtk/keys.py @@ -225,25 +225,22 @@ def toga_key(event): """Convert a GDK Key Event into a Toga key.""" - try: - key = GDK_KEYS[event.keyval] + key = GDK_KEYS[event.keyval] - modifiers = set() + modifiers = set() - if event.state & Gdk.ModifierType.LOCK_MASK: - modifiers.add(Key.CAPSLOCK) - if event.state & Gdk.ModifierType.SHIFT_MASK: - modifiers.add(Key.SHIFT) - if event.state & Gdk.ModifierType.CONTROL_MASK: - modifiers.add(Key.MOD_1) - if event.state & Gdk.ModifierType.META_MASK: - modifiers.add(Key.MOD_2) - if event.state & Gdk.ModifierType.HYPER_MASK: - modifiers.add(Key.MOD_3) + if event.state & Gdk.ModifierType.LOCK_MASK: + modifiers.add(Key.CAPSLOCK) + if event.state & Gdk.ModifierType.SHIFT_MASK: + modifiers.add(Key.SHIFT) + if event.state & Gdk.ModifierType.CONTROL_MASK: + modifiers.add(Key.MOD_1) + if event.state & Gdk.ModifierType.META_MASK: + modifiers.add(Key.MOD_2) + if event.state & Gdk.ModifierType.HYPER_MASK: + modifiers.add(Key.MOD_3) - return {"key": key, "modifiers": modifiers} - except KeyError: - return None + return {"key": key, "modifiers": modifiers} def gtk_accel(shortcut): diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index afc10ae99d..1ef6ff93b1 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -6,8 +6,6 @@ class Window: - _IMPL_CLASS = Gtk.Window - def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self @@ -16,7 +14,7 @@ def __init__(self, interface, title, position, size): self.layout = None - self.native = self._IMPL_CLASS() + self.create() self.native._impl = self self.native.connect("delete-event", self.gtk_delete_event) @@ -51,6 +49,9 @@ def __init__(self, interface, title, position, size): self.native.add(self.layout) + def create(self): + self.native = Gtk.Window() + def get_title(self): return self.native.get_title() @@ -64,6 +65,7 @@ def create_toolbar(self): # Remove any pre-existing toolbar content if self.toolbar_items: self.native_toolbar.set_visible(False) + for cmd, item_impl in self.toolbar_items.items(): self.native_toolbar.remove(item_impl) try: @@ -73,6 +75,7 @@ def create_toolbar(self): pass # Create the new toolbar items + self.toolbar_items = {} for cmd in self.interface.toolbar: if cmd == GROUP_BREAK: item_impl = Gtk.SeparatorToolItem() @@ -83,7 +86,9 @@ def create_toolbar(self): else: item_impl = Gtk.ToolButton() if cmd.icon: - item_impl.set_icon_widget(cmd.icon._impl.native_32) + item_impl.set_icon_widget( + Gtk.Image.new_from_pixbuf(cmd.icon._impl.native_32.get_pixbuf()) + ) item_impl.set_label(cmd.text) if cmd.tooltip: item_impl.set_tooltip_text(cmd.tooltip) @@ -91,6 +96,7 @@ def create_toolbar(self): 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() diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index ee7f4af907..9c87786483 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -92,17 +92,17 @@ def activate_menu_about(self): self._activate_menu_item(["Help", "About Toga Testbed"]) def close_about_dialog(self): - self.app._impl.native_about_dialog.close() + self.app._impl._close_about(self.app._impl.native_about_dialog) def activate_menu_visit_homepage(self): - self._activate_menu_item(["Help", "Visit homepage"]) + # Homepage is a link on the GTK about page. + pytest.xfail("GTK doesn't have a visit homepage menu item") def assert_system_menus(self): self.assert_menu_item(["*", "Preferences"], enabled=False) self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) self.assert_menu_item(["Help", "About Toga Testbed"], enabled=True) - self.assert_menu_item(["Help", "Visit homepage"], enabled=True) def activate_menu_close_window(self): pytest.xfail("GTK doesn't have a window management menu items") diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index b58df20b6b..0e23b38d6d 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -240,9 +240,10 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): def has_toolbar(self): return self.impl.native_toolbar.get_n_items() > 0 - def assert_is_toolbar_separator(self, index): + def assert_is_toolbar_separator(self, index, section=False): item = self.impl.native_toolbar.get_nth_item(index) assert isinstance(item, Gtk.SeparatorToolItem) + assert item.get_draw() == (not section) def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): item = self.impl.native_toolbar.get_nth_item(index) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 2cf96965ba..8e39d02ae3 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -55,7 +55,7 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): shortcut=toga.Key.MOD_1 + "1", group=group, ) - # A command with everything + # A command with no tooltip cmd2 = toga.Command( action, "No Tooltip", @@ -69,8 +69,16 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): tooltip="A command with no icon", shortcut=toga.Key.MOD_1 + "3", ) + # A command in another section + cmd4 = toga.Command( + action, + "Sectioned", + icon=toga.Icon.DEFAULT_ICON, + tooltip="I'm in another section", + section=2, + ) - main_window.toolbar.add(cmd1, cmd2, cmd3) + main_window.toolbar.add(cmd1, cmd2, cmd3, cmd4) await main_window_probe.redraw("Main window has a toolbar") assert main_window_probe.has_toolbar() @@ -97,6 +105,14 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): has_icon=True, enabled=True, ) + main_window_probe.assert_is_toolbar_separator(4, section=True) + main_window_probe.assert_toolbar_item( + 5, + label="Sectioned", + tooltip="I'm in another section", + has_icon=True, + enabled=True, + ) # Press the first toolbar button main_window_probe.press_toolbar_button(0) @@ -173,6 +189,8 @@ async def test_menu_about(monkeypatch, app, app_probe): # Make the app definition minimal to verify the dialog still displays monkeypatch.setattr(app, "_author", None) monkeypatch.setattr(app, "_version", None) + monkeypatch.setattr(app, "_home_page", None) + monkeypatch.setattr(app, "_description", None) app_probe.activate_menu_about() await app_probe.redraw("About dialog with no details shown") From adcecf29d4a8188aa9f06a63cb0b94907c1ec1a2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 17 Aug 2023 08:54:07 +0800 Subject: [PATCH 18/66] Short delay to improve test reliability. --- gtk/tests_backend/widgets/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 1d467e7f53..ba5a0f5019 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -1,3 +1,4 @@ +import asyncio from threading import Event from toga_gtk.libs import Gdk, Gtk @@ -160,3 +161,8 @@ def event_handled(widget, e): # Remove the temporary handler self._keypress_target.disconnect(handler_id) + + # GTK has an intermittent failure because on_change handler + # caused by typing a character doesn't fully propegate. A + # short delay fixes this. + await asyncio.sleep(0.04) From ada9303eea8f9d3ffbdd6821a39cb1f2ec8412b9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 17 Aug 2023 10:17:59 +0800 Subject: [PATCH 19/66] Add a missing await. --- testbed/tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 8e39d02ae3..687a42609f 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -259,7 +259,7 @@ async def test_menu_items(app, app_probe): # Add all the commands app.commands.add(cmd1, cmd2, cmd3, disabled_item, no_action, deep_item, cmd4) - app_probe.redraw("App has custom menu items") + await app_probe.redraw("App has custom menu items") app_probe.assert_menu_item( ["Other", "Full command"], From cceeb5f95c2e6eb2b2b1ff2d179f1a67dac2839d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 17 Aug 2023 10:18:21 +0800 Subject: [PATCH 20/66] Add some CI debugging help. --- testbed/tests/testbed.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 244294b5e4..cccb4e19c2 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -17,7 +17,9 @@ def run_tests(app, cov, args, report_coverage, run_slow): try: # Control the run speed of the test app. - app.run_slow = run_slow + # FIXME: Add this in to debug why apps are crashing in CI. + app.run_slow = True + # app.run_slow = run_slow project_path = Path(__file__).parent.parent os.chdir(project_path) From 9f2436108237b631a244e1ce0c8426630df4309f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 23 Aug 2023 11:20:16 +0800 Subject: [PATCH 21/66] 100% coverage of app on iOS. --- iOS/src/toga_iOS/app.py | 33 +- iOS/src/toga_iOS/libs/__init__.py | 1 + iOS/src/toga_iOS/libs/av_foundation.py | 13 + iOS/tests_backend/app.py | 34 +- iOS/tests_backend/window.py | 3 + testbed/tests/test_app.py | 582 ++++++++++++++----------- 6 files changed, 397 insertions(+), 269 deletions(-) create mode 100644 iOS/src/toga_iOS/libs/av_foundation.py diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 48c4f7f653..343790c2a1 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -3,7 +3,7 @@ from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle -from toga_iOS.libs import UIResponder +from toga_iOS.libs import UIResponder, av_foundation from toga_iOS.window import Window @@ -67,10 +67,14 @@ def create(self): """Calls the startup method on the interface.""" self.interface._startup() - def open_document(self, fileURL): + def open_document(self, fileURL): # pragma: no cover """Add a new document to this app.""" pass + def create_menus(self): + # No menus on an iOS app (for now) + pass + 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 @@ -81,17 +85,38 @@ def main_loop(self): def set_main_window(self, window): pass + def get_current_window(self): + # iOS only has a main window. + return self.interface.main_window._impl + + def set_current_window(self, window): + # iOS only has a main window, so this is a no-op + pass + def show_about_dialog(self): self.interface.factory.not_implemented("App.show_about_dialog()") def beep(self): - self.interface.factory.not_implemented("App.beep()") + # 1013 is a magic constant that is the "SMS RECEIVED 5" sound, + # sounding like a single strike of a bell. + av_foundation.AudioServicesPlayAlertSound(1013) + + def exit(self): # pragma: no cover + # Mobile apps can't be exited, but the entry point needs to exist + pass + + def enter_full_screen(self, windows): + # No-op; mobile doesn't support full screen + pass - def exit(self): + def exit_full_screen(self, windows): + # No-op; mobile doesn't support full screen pass def hide_cursor(self): + # No-op; mobile doesn't support cursors pass def show_cursor(self): + # No-op; mobile doesn't support cursors pass diff --git a/iOS/src/toga_iOS/libs/__init__.py b/iOS/src/toga_iOS/libs/__init__.py index 105a6400a7..50dd37acf7 100644 --- a/iOS/src/toga_iOS/libs/__init__.py +++ b/iOS/src/toga_iOS/libs/__init__.py @@ -1,3 +1,4 @@ +from .av_foundation import * # NOQA from .core_graphics import * # NOQA from .foundation import * # NOQA from .uikit import * # NOQA diff --git a/iOS/src/toga_iOS/libs/av_foundation.py b/iOS/src/toga_iOS/libs/av_foundation.py new file mode 100644 index 0000000000..5367d885e5 --- /dev/null +++ b/iOS/src/toga_iOS/libs/av_foundation.py @@ -0,0 +1,13 @@ +########################################################################## +# System/Library/Frameworks/AVFoundation.framework +########################################################################## +from ctypes import c_uint32, cdll, util + +###################################################################### +av_foundation = cdll.LoadLibrary(util.find_library("AVFoundation")) +###################################################################### + +SystemSoundID = c_uint32 + +av_foundation.AudioServicesPlayAlertSound.restype = None +av_foundation.AudioServicesPlayAlertSound.argtypes = [SystemSoundID] diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index ec8a355599..a62fd46cb5 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from toga_iOS.libs import ( NSFileManager, NSSearchPathDirectory, @@ -14,7 +16,8 @@ class AppProbe(BaseProbe): def __init__(self, app): super().__init__() self.app = app - assert isinstance(self.app._impl.native, UIApplication) + self.native = self.app._impl.native + assert isinstance(self.native, UIApplication) def get_path(self, search_path): file_manager = NSFileManager.defaultManager @@ -38,3 +41,32 @@ def cache_path(self): @property def logs_path(self): return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Logs" + + def assert_system_menus(self): + pytest.skip("Menus not implemented on iOS") + + def activate_menu_about(self): + pytest.skip("Menus not implemented on iOS") + + def activate_menu_visit_homepage(self): + pytest.skip("Menus not implemented on iOS") + + def assert_menu_item(self, path, enabled): + pytest.skip("Menus not implemented on iOS") + + def keystroke(self, combination): + pytest.skip("iOS doesn't use keyboard shortcuts") + + def enter_background(self): + self.native.delegate.applicationWillResignActive(self.native) + self.native.delegate.applicationDidEnterBackground(self.native) + + def enter_foreground(self): + self.native.delegate.applicationWillEnterForeground(self.native) + + def terminate(self): + self.native.delegate.applicationWillTerminate(self.native) + + def rotate(self): + self.native = self.app._impl.native + self.native.delegate.application(self.native, didChangeStatusBarOrientation=0) diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 199353464c..e7c5d30b15 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -69,3 +69,6 @@ async def close_open_file_dialog(self, dialog, result, multiple_select): async def close_select_folder_dialog(self, dialog, result, multiple_select): pytest.skip("Select Folder dialog not implemented on iOS") + + def has_toolbar(self): + pytest.skip("Toolbars not implemented on iOS") diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 687a42609f..fd843c620c 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -17,28 +17,328 @@ def mock_app_exit(monkeypatch, app): return app_exit -async def test_exit_on_close_main_window(app, main_window_probe, mock_app_exit): - """An app can be exited by closing the main window""" - on_exit_handler = Mock(return_value=False) - app.on_exit = on_exit_handler +# 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_show_hide_cursor(app): + """The app cursor methods can be invoked""" + # Invoke the methods to verify the endpoints exist. However, they're no-ops, + # so there's nothing to test. + app.show_cursor() + app.hide_cursor() + + async def test_full_screen(app): + """Window can be made full screen""" + # Invoke the methods to verify the endpoints exist. However, they're no-ops, + # so there's nothing to test. + app.set_full_screen(app.current_window) + app.exit_full_screen() + + async def test_current_window(app, main_window, main_window_probe): + """The current window can be retrieved""" + assert app.current_window == main_window + + # Explicitly set the current window + app.current_window = main_window + await main_window_probe.wait_for_window("Main window is still current") + assert app.current_window == main_window + + async def test_app_lifecycle(app, app_probe): + """Application lifecycle can be exercised""" + app_probe.enter_background() + await app_probe.redraw("App pre-background logic has been invoked") + + app_probe.enter_foreground() + await app_probe.redraw("App restoration logic has been invoked") + + app_probe.terminate() + await app_probe.redraw("App pre-termination logic has been invoked") + + async def test_device_rotation(app, app_probe): + """App responds to device rotation""" + app_probe.rotate() + await app_probe.redraw("Device has been rotated") + +else: + #################################################################################### + # Desktop platform tests + #################################################################################### + + async def test_exit_on_close_main_window(app, main_window_probe, mock_app_exit): + """An app can be exited by closing the main window""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Close the main window + main_window_probe.close() + await main_window_probe.redraw("Main window close requested, but rejected") + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + main_window_probe.close() + await main_window_probe.redraw("Main window close requested, and accepted") + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() + + async def test_menu_exit(app, app_probe, mock_app_exit): + """An app can be exited by using the menu item""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler - # Close the main window - main_window_probe.close() - await main_window_probe.redraw("Main window close requested, but rejected") + # Close the main window + app_probe.activate_menu_exit() + await app_probe.redraw("Exit selected from menu, but rejected") + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + app_probe.activate_menu_exit() + await app_probe.redraw("Exit selected from menu, and accepted") + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() - # on_exit_handler was invoked, rejecting the close; so the app won't be closed - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_not_called() + async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + window1.show() + window2.show() + window3.show() + + app.current_window = window2 + + await app_probe.redraw("Extra windows added") + + app_probe.activate_menu_close_window() + assert window2 not in app.windows + + await app_probe.redraw("Window 2 closed") + + app_probe.activate_menu_close_all_windows() + + # Close all windows will attempt to close the main window as well. + # This would be an app exit, but we can't allow that; so, the only + # window that *actually* remains will be the main window. + mock_app_exit.assert_called_once_with() + assert window1 not in app.windows + assert window2 not in app.windows + assert window3 not in app.windows + + await app_probe.redraw("Extra windows closed") + + # Now that we've "closed" all the windows, we're in a state where there + # aren't any windows. Patch get_current_window to reflect this. + monkeypatch.setattr( + app._impl, + "get_current_window", + Mock(return_value=None), + ) + app_probe.activate_menu_close_window() + await app_probe.redraw("No windows; Close Window is a no-op") + + app_probe.activate_menu_minimize() + await app_probe.redraw("No windows; Minimize is a no-op") + + finally: + if window1 in app.windows: + window1.close() + if window2 in app.windows: + window2.close() + if window3 in app.windows: + window3.close() + + async def test_menu_minimize(app, app_probe): + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window1.show() + + window1_probe = window_probe(app, window1) + + app.current_window = window1 + await app_probe.redraw("Extra window added") + + app_probe.activate_menu_minimize() + + await window1_probe.wait_for_window("Extra window minimized", minimize=True) + assert window1_probe.is_minimized + finally: + window1.close() - # Reset and try again, this time allowing the exit - on_exit_handler.reset_mock() - on_exit_handler.return_value = True - main_window_probe.close() - await main_window_probe.redraw("Main window close requested, and accepted") + async def test_full_screen(app, app_probe): + """Window can be made full screen""" + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window1_probe = window_probe(app, window1) + window2_probe = window_probe(app, window2) + + window1.show() + window2.show() + await app_probe.redraw("Extra windows are visible") + + assert not app.is_full_screen + assert not app_probe.is_full_screen(window1) + assert not app_probe.is_full_screen(window2) + initial_content1_size = app_probe.content_size(window1) + initial_content2_size = app_probe.content_size(window2) + + # Make window 2 full screen via the app + app.set_full_screen(window2) + await window2_probe.wait_for_window( + "Second extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert app_probe.is_full_screen(window2) + assert app_probe.content_size(window2)[0] > 1000 + assert app_probe.content_size(window2)[1] > 1000 + + # Make window 1 full screen via the app, window 2 no longer full screen + app.set_full_screen(window1) + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 1000 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen + app.exit_full_screen() + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Go full screen again on window 1 + app.set_full_screen(window1) + # A longer delay to allow for genie animations + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 1000 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen by passing no windows + app.set_full_screen() + + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + finally: + window1.close() + window2.close() - # on_exit_handler was invoked and accepted, so the mocked exit() was called. - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_called_once_with() + async def test_show_hide_cursor(app, app_probe): + """The app cursor can be hidden and shown""" + app.hide_cursor() + await app_probe.redraw("Cursor is hidden") + assert not app_probe.is_cursor_visible + + # Hiding again can't make it more hidden + app.hide_cursor() + await app_probe.redraw("Cursor is still hidden") + assert not app_probe.is_cursor_visible + + # Show the cursor again + app.show_cursor() + await app_probe.redraw("Cursor is visible") + assert app_probe.is_cursor_visible + + # Hiding again can't make it more hidden + app.show_cursor() + await app_probe.redraw("Cursor is still visible") + assert app_probe.is_cursor_visible + + async def test_current_window(app, app_probe): + """The current window can be retrieved.""" + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + window2_probe = window_probe(app, window2) + window3_probe = window_probe(app, window2) + + window1.show() + window2.show() + window3.show() + + await app_probe.redraw("Extra windows added") + + app.current_window = window2 + await window2_probe.wait_for_window("Window 2 is current") + assert app.current_window == window2 + + app.current_window = window3 + await window3_probe.wait_for_window("Window 3 is current") + assert app.current_window == window3 + + # app_probe.platform tests? + finally: + window1.close() + window2.close() + window3.close() async def test_main_window_toolbar(app, main_window, main_window_probe): @@ -154,30 +454,6 @@ async def test_system_menus(app_probe): app_probe.assert_system_menus() -async def test_menu_exit(app, app_probe, mock_app_exit): - """An app can be exited by using the menu item""" - on_exit_handler = Mock(return_value=False) - app.on_exit = on_exit_handler - - # Close the main window - app_probe.activate_menu_exit() - await app_probe.redraw("Exit selected from menu, but rejected") - - # on_exit_handler was invoked, rejecting the close; so the app won't be closed - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_not_called() - - # Reset and try again, this time allowing the exit - on_exit_handler.reset_mock() - on_exit_handler.return_value = True - app_probe.activate_menu_exit() - await app_probe.redraw("Exit selected from menu, and accepted") - - # on_exit_handler was invoked and accepted, so the mocked exit() was called. - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_called_once_with() - - async def test_menu_about(monkeypatch, app, app_probe): """The about menu can be displayed""" app_probe.activate_menu_about() @@ -305,231 +581,9 @@ async def test_menu_items(app, app_probe): ) -async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): - try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) - window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - - window1.show() - window2.show() - window3.show() - - app.current_window = window2 - - await app_probe.redraw("Extra windows added") - - app_probe.activate_menu_close_window() - assert window2 not in app.windows - - await app_probe.redraw("Window 2 closed") - - app_probe.activate_menu_close_all_windows() - - # Close all windows will attempt to close the main window as well. - # This would be an app exit, but we can't allow that; so, the only - # window that *actually* remains will be the main window. - mock_app_exit.assert_called_once_with() - assert window1 not in app.windows - assert window2 not in app.windows - assert window3 not in app.windows - - await app_probe.redraw("Extra windows closed") - - # Now that we've "closed" all the windows, we're in a state where there - # aren't any windows. Patch get_current_window to reflect this. - monkeypatch.setattr(app._impl, "get_current_window", Mock(return_value=None)) - app_probe.activate_menu_close_window() - await app_probe.redraw("No windows; Close Window is a no-op") - - app_probe.activate_menu_minimize() - await app_probe.redraw("No windows; Minimize is a no-op") - - finally: - if window1 in app.windows: - window1.close() - if window2 in app.windows: - window2.close() - if window3 in app.windows: - window3.close() - - -async def test_menu_minimize(app, app_probe): - try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window1.show() - - window1_probe = window_probe(app, window1) - - app.current_window = window1 - await app_probe.redraw("Extra window added") - - app_probe.activate_menu_minimize() - - await window1_probe.wait_for_window("Extra window minimized", minimize=True) - assert window1_probe.is_minimized - finally: - window1.close() - - async def test_beep(app): """The machine can go Bing!""" # This isn't a very good test. It ensures coverage, which verifies that the method # can be invoked without raising an error, but there's no way to verify that the app # actually made a noise. app.beep() - - -async def test_current_window(app, app_probe): - """The current window can be retrieved.""" - try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) - window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - - window2_probe = window_probe(app, window2) - window3_probe = window_probe(app, window2) - - window1.show() - window2.show() - window3.show() - - await app_probe.redraw("Extra windows added") - - app.current_window = window2 - await window2_probe.wait_for_window("Window 2 is current") - assert app.current_window == window2 - - app.current_window = window3 - await window3_probe.wait_for_window("Window 3 is current") - assert app.current_window == window3 - - # app_probe.platform tests? - finally: - window1.close() - window2.close() - window3.close() - - -async def test_full_screen(app, app_probe): - """Window can be made full screen""" - try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window1_probe = window_probe(app, window1) - window2_probe = window_probe(app, window2) - - window1.show() - window2.show() - await app_probe.redraw("Extra windows are visible") - - assert not app.is_full_screen - assert not app_probe.is_full_screen(window1) - assert not app_probe.is_full_screen(window2) - initial_content1_size = app_probe.content_size(window1) - initial_content2_size = app_probe.content_size(window2) - - # Make window 2 full screen via the app - app.set_full_screen(window2) - await window2_probe.wait_for_window( - "Second extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert app_probe.is_full_screen(window2) - assert app_probe.content_size(window2)[0] > 1000 - assert app_probe.content_size(window2)[1] > 1000 - - # Make window 1 full screen via the app, window 2 no longer full screen - app.set_full_screen(window1) - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 1000 - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Exit full screen - app.exit_full_screen() - await window1_probe.wait_for_window("No longer full screen", full_screen=True) - - assert not app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Go full screen again on window 1 - app.set_full_screen(window1) - # A longer delay to allow for genie animations - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 1000 - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Exit full screen by passing no windows - app.set_full_screen() - - await window1_probe.wait_for_window("No longer full screen", full_screen=True) - assert not app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - finally: - window1.close() - window2.close() - - -async def test_show_hide_cursor(app, app_probe): - """The app cursor can be hidden and shown""" - app.hide_cursor() - await app_probe.redraw("Cursor is hidden") - assert not app_probe.is_cursor_visible - - # Hiding again can't make it more hidden - app.hide_cursor() - await app_probe.redraw("Cursor is still hidden") - assert not app_probe.is_cursor_visible - - # Show the cursor again - app.show_cursor() - await app_probe.redraw("Cursor is visible") - assert app_probe.is_cursor_visible - - # Hiding again can't make it more hidden - app.show_cursor() - await app_probe.redraw("Cursor is still visible") - assert app_probe.is_cursor_visible From c6fe6402d56e3c1666124437f43d58d76b6a2bd8 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 25 Aug 2023 12:31:49 +0800 Subject: [PATCH 22/66] Corrections to object lifecycle and test probes to pass CI. --- cocoa/src/toga_cocoa/app.py | 18 +++-- cocoa/src/toga_cocoa/icons.py | 23 +++++- cocoa/src/toga_cocoa/window.py | 27 ++++++- cocoa/tests_backend/app.py | 27 +++++-- testbed/src/testbed/app.py | 72 +++++++++++++++++ testbed/tests/conftest.py | 4 +- testbed/tests/test_app.py | 128 ++++++++---------------------- testbed/tests/test_window.py | 17 +--- testbed/tests/testbed.py | 5 +- testbed/tests/widgets/conftest.py | 2 +- 10 files changed, 195 insertions(+), 128 deletions(-) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 8e1b0b4c4a..24299b61eb 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -147,6 +147,8 @@ def create(self): # Create the lookup table of menu items, # then force the creation of the menus. + self._menu_groups = {} + self._menu_items = {} self.create_menus() def _create_app_commands(self): @@ -160,7 +162,7 @@ def _create_app_commands(self): ), toga.Command( None, - "Preferences", + "Settings\u2026", shortcut=toga.Key.MOD_1 + ",", group=toga.Group.APP, section=20, @@ -320,16 +322,21 @@ def _menu_visit_homepage(self, app, **kwargs): self.interface.visit_homepage() def create_menus(self): + # Purge any existing menu items + while self._menu_groups: + _, submenu = self._menu_groups.popitem() + while self._menu_items: + item, cmd = self._menu_items.popitem() + cmd._impl.native.remove(item) + # Recreate the menu - self._menu_items = {} - self._menu_groups = {} menubar = NSMenu.alloc().initWithTitle("MainMenu") submenu = None for cmd in self.interface.commands: if cmd == GROUP_BREAK: submenu = None elif cmd == SECTION_BREAK: - submenu.addItem_(NSMenuItem.separatorItem()) + submenu.addItem(NSMenuItem.separatorItem()) else: submenu = self._submenu(cmd.group, menubar) @@ -352,6 +359,7 @@ def create_menus(self): action=action, keyEquivalent=key, ) + if modifier is not None: item.keyEquivalentModifierMask = modifier @@ -489,7 +497,7 @@ def _create_app_commands(self): self.interface.commands.add( toga.Command( self._menu_open_file, - text="Open...", + text="Open\u2026", shortcut=toga.Key.MOD_1 + "o", group=toga.Group.FILE, section=0, diff --git a/cocoa/src/toga_cocoa/icons.py b/cocoa/src/toga_cocoa/icons.py index 6fde889dbd..b023033310 100644 --- a/cocoa/src/toga_cocoa/icons.py +++ b/cocoa/src/toga_cocoa/icons.py @@ -9,5 +9,26 @@ def __init__(self, interface, path): self.interface = interface self.interface._impl = self self.path = path + try: + # We *should* be able to do a direct NSImage.alloc.init...(), but if the + # image file is invalid, the init fails, and returns NULL - but we've + # created an ObjC instance, so when the object passes out of scope, Rubicon + # tries to free it, which segfaults. To avoid this, we retain result of the + # alloc() (overriding the default Rubicon behavior of alloc), then release + # that reference once we're done. If the image was created successfully, we + # temporarily have a reference count that is 1 higher than it needs to be; + # if it fails, we don't end up with a stray release. + image = NSImage.alloc().retain() + self.native = image.initWithContentsOfFile(str(path)) + if self.native is None: + raise ValueError(f"Unable to load icon from {path}") + finally: + image.release() - self.native = NSImage.alloc().initWithContentsOfFile(str(path)) + # Multiple icon interface instances can end up referencing the same native + # instance, so make sure we retain a reference count at the impl level. + self.native.retain() + + def __del__(self): + if self.native: + self.native.release() diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 81a788c872..1bcfab8425 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -149,14 +149,39 @@ def __init__(self, interface, title, position, size): self.native.contentView = self.container.native # By default, no toolbar + self._toolbar_items = {} self.native_toolbar = None def __del__(self): + self.purge_toolbar() self.native.release() + 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 create_toolbar(self): + # Purge any existing toolbar items + self.purge_toolbar() + + # Create the new toolbar items. if self.interface.toolbar: - self._toolbar_items = {} for cmd in self.interface.toolbar: if isinstance(cmd, Command): self._toolbar_items[toolbar_identifier(cmd)] = cmd diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 31ff9ae92f..69e39fd21c 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -60,15 +60,26 @@ def _menu_item(self, path): menu = main_menu orig_path = path.copy() - try: - while True: - label, path = path[0], path[1:] - item = menu.itemWithTitle(label) + while True: + label, path = path[0], path[1:] + item = menu.itemWithTitle(label) + if item is None: + raise AssertionError( + f"Menu {' > '.join(orig_path)} not found; " + f"no item named {label!r}; options are: " + + ",".join(f"{str(item.title)!r}" for item in menu.itemArray) + ) + + if path: menu = item.submenu - except IndexError: - pass - except AttributeError: - raise AssertionError(f"Menu {' > '.join(orig_path)} not found") + if menu is None: + raise AssertionError( + f"Menu {' > '.join(orig_path)} not found; " + f"{str(item.title)} does not have a submenu" + ) + else: + # No more path segments; we've found the full path. + break return item diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 20ffd097fe..506c70b5aa 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import toga @@ -8,6 +10,76 @@ def startup(self): # suite exiting/crashing. self.returncode = -1 + # Commands exist on the app's lifecycle, and the app API isn't designed to deal + # with destroying commands, so we create all the commands up front for the app + # to use. + + self.cmd_action = Mock() + # A command with everything, in a group + group = toga.Group("Other") + self.cmd1 = toga.Command( + self.cmd_action, + "Full command", + icon=toga.Icon.DEFAULT_ICON, + tooltip="A full command definition", + shortcut=toga.Key.MOD_1 + "1", + group=group, + ) + # A command with no tooltip, in the default group + self.cmd2 = toga.Command( + self.cmd_action, + "No Tooltip", + icon=toga.Icon.DEFAULT_ICON, + shortcut=toga.Key.MOD_1 + "2", + ) + # A command without an icon, in the default group + self.cmd3 = toga.Command( + self.cmd_action, + "No Icon", + tooltip="A command with no icon", + shortcut=toga.Key.MOD_1 + "3", + ) + # A command in another section + self.cmd4 = toga.Command( + self.cmd_action, + "Sectioned", + icon=toga.Icon.DEFAULT_ICON, + tooltip="I'm in another section", + section=2, + ) + # Submenus inside the "other" group + subgroup1 = toga.Group("Submenu1", section=2, parent=group) + subgroup1_1 = toga.Group("Submenu1 menu1", parent=subgroup1) + subgroup2 = toga.Group("Submenu2", section=2, parent=group) + + # Items on submenu1 + # An item that is disabled by default + self.disabled_cmd = toga.Command( + self.cmd_action, + "Disabled", + enabled=False, + group=subgroup1, + ) + # An item that has no action + self.no_action_cmd = toga.Command(None, "No Action", group=subgroup1) + # An item deep in a menu + self.deep_cmd = toga.Command(self.cmd_action, "Deep", group=subgroup1_1) + + # Items on submenu2 + self.cmd5 = toga.Command(self.cmd_action, "Jiggle", group=subgroup2) + + # Add all the commands + self.commands.add( + self.cmd1, + self.cmd2, + self.cmd3, + self.cmd4, + self.disabled_cmd, + self.no_action_cmd, + self.deep_cmd, + self.cmd5, + ) + self.main_window = toga.MainWindow(title=self.formal_name) self.main_window.content = toga.Box( children=[ diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index a471037968..af8e57d745 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -52,7 +52,7 @@ def main_window(app): return app.main_window -@fixture +@fixture(scope="session") async def main_window_probe(app, main_window): old_content = main_window.content @@ -60,6 +60,8 @@ async def main_window_probe(app, main_window): main_window.content = toga.Box(style=Pack(background_color=GOLDENROD)) module = import_module("tests_backend.window") + if app.run_slow: + print("\nConstructing Window probe") yield getattr(module, "WindowProbe")(app, main_window) main_window.content = old_content diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index fd843c620c..6d71eaa839 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -219,7 +219,7 @@ async def test_full_screen(app, app_probe): assert app_probe.is_full_screen(window2) assert app_probe.content_size(window2)[0] > 1000 - assert app_probe.content_size(window2)[1] > 1000 + assert app_probe.content_size(window2)[1] > 700 # Make window 1 full screen via the app, window 2 no longer full screen app.set_full_screen(window1) @@ -231,7 +231,7 @@ async def test_full_screen(app, app_probe): assert app_probe.is_full_screen(window1) assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 1000 + assert app_probe.content_size(window1)[1] > 700 assert not app_probe.is_full_screen(window2) assert app_probe.content_size(window2) == initial_content2_size @@ -262,7 +262,7 @@ async def test_full_screen(app, app_probe): assert app_probe.is_full_screen(window1) assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 1000 + assert app_probe.content_size(window1)[1] > 700 assert not app_probe.is_full_screen(window2) assert app_probe.content_size(window2) == initial_content2_size @@ -343,42 +343,8 @@ async def test_current_window(app, app_probe): async def test_main_window_toolbar(app, main_window, main_window_probe): """A toolbar can be added to a main window""" - action = Mock() - - # A command with everything - group = toga.Group("Other") - cmd1 = toga.Command( - action, - "Full command", - icon=toga.Icon.DEFAULT_ICON, - tooltip="A full command definition", - shortcut=toga.Key.MOD_1 + "1", - group=group, - ) - # A command with no tooltip - cmd2 = toga.Command( - action, - "No Tooltip", - icon=toga.Icon.DEFAULT_ICON, - shortcut=toga.Key.MOD_1 + "2", - ) - # A command without an icon - cmd3 = toga.Command( - action, - "No Icon", - tooltip="A command with no icon", - shortcut=toga.Key.MOD_1 + "3", - ) - # A command in another section - cmd4 = toga.Command( - action, - "Sectioned", - icon=toga.Icon.DEFAULT_ICON, - tooltip="I'm in another section", - section=2, - ) - - main_window.toolbar.add(cmd1, cmd2, cmd3, cmd4) + # Add some items to the main window toolbar + main_window.toolbar.add(app.cmd1, app.cmd2, app.cmd3, app.cmd4) await main_window_probe.redraw("Main window has a toolbar") assert main_window_probe.has_toolbar() @@ -417,11 +383,11 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): # Press the first toolbar button main_window_probe.press_toolbar_button(0) await main_window_probe.redraw("Command 1 invoked") - action.assert_called_once_with(cmd1) - action.reset_mock() + app.cmd_action.assert_called_once_with(app.cmd1) + app.cmd_action.reset_mock() # Disable the first toolbar button - cmd1.enabled = False + app.cmd1.enabled = False await main_window_probe.redraw("Command 1 disabled") main_window_probe.assert_toolbar_item( 0, @@ -432,7 +398,7 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): ) # Re-enable the first toolbar button - cmd1.enabled = True + app.cmd1.enabled = True await main_window_probe.redraw("Command 1 re-enabled") main_window_probe.assert_toolbar_item( 0, @@ -457,7 +423,8 @@ async def test_system_menus(app_probe): async def test_menu_about(monkeypatch, app, app_probe): """The about menu can be displayed""" app_probe.activate_menu_about() - await app_probe.redraw("About dialog shown") + # When in CI, Cocoa needs a little time to guarantee the dialog is displayed. + await app_probe.redraw("About dialog shown", delay=0.1) app_probe.close_about_dialog() await app_probe.redraw("About dialog destroyed") @@ -469,7 +436,8 @@ async def test_menu_about(monkeypatch, app, app_probe): monkeypatch.setattr(app, "_description", None) app_probe.activate_menu_about() - await app_probe.redraw("About dialog with no details shown") + # When in CI, Cocoa needs a little time to guarantee the dialog is displayed. + await app_probe.redraw("About dialog with no details shown", delay=0.1) app_probe.close_about_dialog() await app_probe.redraw("About dialog with no details destroyed") @@ -490,52 +458,6 @@ async def test_menu_visit_homepage(monkeypatch, app, app_probe): async def test_menu_items(app, app_probe): """Menu items can be created, disabled and invoked""" - action = Mock() - - # A command with everything - group = toga.Group("Other") - cmd1 = toga.Command( - action, - "Full command", - icon=toga.Icon.DEFAULT_ICON, - tooltip="A full command definition", - shortcut=toga.Key.MOD_1 + "1", - group=group, - ) - # A command with everything - cmd2 = toga.Command( - action, - "No Tooltip", - icon=toga.Icon.DEFAULT_ICON, - shortcut=toga.Key.MOD_1 + "2", - ) - # A command without an icon - cmd3 = toga.Command( - action, - "No Icon", - tooltip="A command with no icon", - shortcut=toga.Key.MOD_1 + "3", - ) - # Submenus inside the "other" group - subgroup1 = toga.Group("Submenu1", section=2, parent=group) - subgroup1_1 = toga.Group("Submenu1 menu1", parent=subgroup1) - subgroup2 = toga.Group("Submenu2", section=2, parent=group) - - # Items on submenu1 - # An item that is disabled by default - disabled_item = toga.Command(action, "Disabled", enabled=False, group=subgroup1) - # An item that has no action - no_action = toga.Command(None, "No Action", group=subgroup1) - # An item deep in a menu - deep_item = toga.Command(action, "Deep", group=subgroup1_1) - - # Items on submenu2 - cmd4 = toga.Command(action, "Jiggle", group=subgroup2) - - # Add all the commands - app.commands.add(cmd1, cmd2, cmd3, disabled_item, no_action, deep_item, cmd4) - - await app_probe.redraw("App has custom menu items") app_probe.assert_menu_item( ["Other", "Full command"], @@ -554,10 +476,24 @@ async def test_menu_items(app, app_probe): enabled=True, ) + app_probe.assert_menu_item( + ["Commands", "No Tooltip"], + enabled=True, + ) + app_probe.assert_menu_item( + ["Commands", "No Icon"], + enabled=True, + ) + app_probe.assert_menu_item( + ["Commands", "Sectioned"], + enabled=True, + ) + # Enabled the disabled items - disabled_item.enabled = True - no_action.enabled = True + app.disabled_cmd.enabled = True + app.no_action_cmd.enabled = True await app_probe.redraw("Menu items enabled") + app_probe.assert_menu_item( ["Other", "Submenu1", "Disabled"], enabled=True, @@ -568,8 +504,10 @@ async def test_menu_items(app, app_probe): enabled=False, ) - disabled_item.enabled = False - no_action.enabled = False + # Dislble the items + app.disabled_cmd.enabled = False + app.no_action_cmd.enabled = False + await app_probe.redraw("Menu item disabled again") app_probe.assert_menu_item( ["Other", "Submenu1", "Disabled"], diff --git a/testbed/tests/test_window.py b/testbed/tests/test_window.py index c526ea9d1b..1a5f521fe8 100644 --- a/testbed/tests/test_window.py +++ b/testbed/tests/test_window.py @@ -221,22 +221,9 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob "second_window_kwargs", [dict(title="Secondary Window", position=(200, 300), size=(400, 200))], ) - async def test_secondary_window_toolbar(second_window, second_window_probe): + async def test_secondary_window_toolbar(app, second_window, second_window_probe): """A toolbar can be added to a secondary window""" - action = Mock() - - # A command with everything - group = toga.Group("Other") - cmd1 = toga.Command( - action, - "Full command", - icon=toga.Icon.DEFAULT_ICON, - tooltip="A full command definition", - shortcut=toga.Key.MOD_1 + "1", - group=group, - ) - - second_window.toolbar.add(cmd1) + second_window.toolbar.add(app.cmd1) # Window doesn't have content. This is intentional. second_window.show() diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 244294b5e4..0ecebcac57 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -151,7 +151,10 @@ def get_terminal_size(*args, **kwargs): report_coverage=report_coverage, ) ) - thread.start() + # Queue a background task to run that will start the main thread. We do this, + # instead of just starting the thread directly, so that we can make sure the App has + # been fully initialized, and the event loop is running. + app.add_background_task(lambda app, **kwargs: thread.start()) # Start the test app. app.main_loop() diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index f63ef067df..db6a90c3ea 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -20,7 +20,7 @@ async def probe(main_window, widget): box = toga.Box(children=[widget]) main_window.content = box probe = get_probe(widget) - await probe.redraw(f"Constructing {widget.__class__.__name__} probe") + await probe.redraw(f"\nConstructing {widget.__class__.__name__} probe") probe.assert_container(box) yield probe From 1334fc695b089d7f79efa7e42401c101c039c394 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Oct 2023 12:37:46 +0800 Subject: [PATCH 23/66] Enable 100% coverage requirement on core tests. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d5410f513..bedd5ce88f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: cd core python -m coverage combine python -m coverage html --skip-covered --skip-empty - python -m coverage report --rcfile ../pyproject.toml # --fail-under=100 + python -m coverage report --rcfile ../pyproject.toml --fail-under=100 - name: Upload HTML report if check failed. uses: actions/upload-artifact@v3.1.3 with: From 5cf47cd84ddf85a0cea2679f8cc6c316bb0774a2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Oct 2023 12:42:11 +0800 Subject: [PATCH 24/66] Enable 100% test requirement on testbed tests. --- cocoa/src/toga_cocoa/app.py | 7 ------- cocoa/src/toga_cocoa/widgets/table.py | 16 +--------------- cocoa/src/toga_cocoa/widgets/tree.py | 16 +--------------- iOS/tests_backend/widgets/detailedlist.py | 11 ++++++++--- testbed/tests/testbed.py | 4 ++-- 5 files changed, 12 insertions(+), 42 deletions(-) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 24299b61eb..9128db7b89 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -322,13 +322,6 @@ def _menu_visit_homepage(self, app, **kwargs): self.interface.visit_homepage() def create_menus(self): - # Purge any existing menu items - while self._menu_groups: - _, submenu = self._menu_groups.popitem() - while self._menu_items: - item, cmd = self._menu_items.popitem() - cmd._impl.native.remove(item) - # Recreate the menu menubar = NSMenu.alloc().initWithTitle("MainMenu") submenu = None diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 6e0c616432..0ff8353b2b 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -1,11 +1,7 @@ -from ctypes import c_void_p - -from rubicon.objc import SEL, at, objc_method, objc_property, send_super +from rubicon.objc import SEL, at, objc_method, objc_property from travertino.size import at_least import toga -from toga.keys import Key -from toga_cocoa.keys import toga_key from toga_cocoa.libs import ( NSBezelBorder, NSIndexSet, @@ -83,16 +79,6 @@ def tableView_pasteboardWriterForRow_(self, table, row) -> None: # pragma: no c # this seems to be required to prevent issue 21562075 in AppKit return None - @objc_method - def keyDown_(self, event) -> None: - # any time this table is in focus and a key is pressed, this method will be called - if toga_key(event) == {"key": Key.A, "modifiers": {Key.MOD_1}}: - if self.interface.multiple_select: - self.selectAll(self) - else: - # forward call to super - send_super(__class__, self, "keyDown:", event, argtypes=[c_void_p]) - # TableDelegate methods @objc_method def selectionShouldChangeInTableView_(self, table) -> bool: diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 6af99f1774..505f947c56 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -1,11 +1,7 @@ -from ctypes import c_void_p - -from rubicon.objc import SEL, at, objc_method, objc_property, send_super +from rubicon.objc import SEL, at, objc_method, objc_property from travertino.size import at_least import toga -from toga.keys import Key -from toga_cocoa.keys import toga_key from toga_cocoa.libs import ( NSBezelBorder, NSIndexSet, @@ -159,16 +155,6 @@ def outlineView_pasteboardWriterForItem_( # else: # self.reloadData() - @objc_method - def keyDown_(self, event) -> None: - # any time this table is in focus and a key is pressed, this method will be called - if toga_key(event) == {"key": Key.A, "modifiers": {Key.MOD_1}}: - if self.interface.multiple_select: - self.selectAll(self) - else: - # forward call to super - send_super(__class__, self, "keyDown:", event, argtypes=[c_void_p]) - # OutlineViewDelegate methods @objc_method def outlineViewSelectionDidChange_(self, notification) -> None: diff --git a/iOS/tests_backend/widgets/detailedlist.py b/iOS/tests_backend/widgets/detailedlist.py index 16d990a507..f397797c9b 100644 --- a/iOS/tests_backend/widgets/detailedlist.py +++ b/iOS/tests_backend/widgets/detailedlist.py @@ -1,4 +1,5 @@ import asyncio +import platform from rubicon.objc.api import Block @@ -48,9 +49,13 @@ def assert_cell_content(self, row, title, subtitle, icon=None): @property def max_scroll_position(self): - return max( - 0, int(self.native.contentSize.height - self.native.frame.size.height) - ) + max_value = int(self.native.contentSize.height - self.native.frame.size.height) + # The max value is a little higher on iOS 17. + # Not sure where the 34 extra pixels are coming from. It appears to be + # a constant, independent of the number of rows of data. + if int(platform.release().split(".")[0]) >= 17: + max_value += 34 + return max(0, max_value) @property def scroll_position(self): diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 5627db802b..03f983431e 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -75,8 +75,8 @@ def run_tests(app, cov, args, report_coverage, run_slow): ) if total < 100.0: print("Test coverage is incomplete") - # Uncomment the next line to enforce test coverage - # TODO: app.returncode = 1 + app.returncode = 1 + except BaseException: traceback.print_exc() app.returncode = 1 From b7fe16c065c68edbd51ebe3098bc89d690a7f78a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Oct 2023 13:08:53 +0800 Subject: [PATCH 25/66] Remove backend tests. --- .github/workflows/ci.yml | 55 +-------------------------- android/tests/__init__.py | 0 android/tests/test_implementation.py | 13 ------- cocoa/tests/__init__.py | 0 cocoa/tests/test_implementation.py | 13 ------- core/tests/app/test_app.py | 4 +- gtk/tests/__init__.py | 0 gtk/tests/test_implementation.py | 11 ------ gtk/tests/widgets/__init__.py | 0 iOS/tests/__init__.py | 0 iOS/tests/test_implementation.py | 11 ------ tox.ini | 19 ++------- web/tests/__init__.py | 0 web/tests/test_implementation.py | 11 ------ winforms/tests/__init__.py | 0 winforms/tests/test_implementation.py | 13 ------- 16 files changed, 8 insertions(+), 142 deletions(-) delete mode 100644 android/tests/__init__.py delete mode 100644 android/tests/test_implementation.py delete mode 100644 cocoa/tests/__init__.py delete mode 100644 cocoa/tests/test_implementation.py delete mode 100644 gtk/tests/__init__.py delete mode 100644 gtk/tests/test_implementation.py delete mode 100644 gtk/tests/widgets/__init__.py delete mode 100644 iOS/tests/__init__.py delete mode 100644 iOS/tests/test_implementation.py delete mode 100644 web/tests/__init__.py delete mode 100644 web/tests/test_implementation.py delete mode 100644 winforms/tests/__init__.py delete mode 100644 winforms/tests/test_implementation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bedd5ce88f..52c4afda98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,9 +91,8 @@ jobs: # The $(ls ...) shell expansion is done in the Github environment; # the value of TOGA_INSTALL_COMMAND will be a literal string, # without any shell expansions to perform - TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py-core - cd core - mv .coverage .coverage.${{ matrix.platform }}.${{ matrix.python-version }} + TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py + mv core/.coverage core/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} - name: Store coverage data uses: actions/upload-artifact@v3.1.3 with: @@ -136,56 +135,6 @@ jobs: path: core/htmlcov if: ${{ failure() }} - backend: - runs-on: ${{ matrix.runs-on }} - needs: [package, core] - strategy: - matrix: - backend: [ "android", "cocoa", "gtk", "iOS", "web", "winforms" ] - include: - - runs-on: ubuntu-latest - - python-version: "3.8" # Should be env.min_python_version (https://github.com/actions/runner/issues/480) - - pre-command: - - - backend: cocoa - runs-on: macos-latest - - - backend: gtk - pre-command: | - sudo apt update -y - sudo apt install -y pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-gtk-3.0 - - - backend: iOS - runs-on: macos-latest - - - backend: winforms - runs-on: windows-latest - # Py3.9 is the first Python version for which - # a wheel of pythonnet isn't available on PyPI. - python-version: "3.9" - steps: - - uses: actions/checkout@v4.1.0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.7.1 - with: - python-version: ${{ matrix.python-version }} - - name: Get packages - uses: actions/download-artifact@v3.0.2 - with: - name: ${{ needs.package.outputs.artifact-name }} - - name: Install dev dependencies - run: | - ${{ matrix.pre-command }} - # We don't actually want to install toga-core; - # we just want the dev extras so we have a known version of tox - python -m pip install ./core[dev] - - name: Test - run: | - # The $(ls ...) shell expansion is done in the Github environment; - # the value of TOGA_INSTALL_COMMAND will be a literal string, - # without any shell expansions to perform - TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl) ../$(ls ${{ matrix.backend }}/dist/toga_${{ matrix.backend }}-*.whl)" tox -e py-${{ matrix.backend }} - testbed: runs-on: ${{ matrix.runs-on }} needs: core diff --git a/android/tests/__init__.py b/android/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/android/tests/test_implementation.py b/android/tests/test_implementation.py deleted file mode 100644 index 7c3f93dfb9..0000000000 --- a/android/tests/test_implementation.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "src", "toga_android" - ) - ) - ) -) diff --git a/cocoa/tests/__init__.py b/cocoa/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/cocoa/tests/test_implementation.py b/cocoa/tests/test_implementation.py deleted file mode 100644 index 39e6781b4c..0000000000 --- a/cocoa/tests/test_implementation.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "src", "toga_cocoa" - ) - ) - ) -) diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index c31d8d4b61..dda6a0d0f9 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -409,8 +409,8 @@ def test_current_window(app): """The current window can be set and changed.""" other_window = toga.Window() - # There are three windows - the 2 provided, plus the main window - assert len(app.windows) == 3 + # There are two windows - the main window, plus "other" + assert len(app.windows) == 2 assert_action_performed_with(app, "set_main_window", window=app.main_window) # The initial current window is the main window diff --git a/gtk/tests/__init__.py b/gtk/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gtk/tests/test_implementation.py b/gtk/tests/test_implementation.py deleted file mode 100644 index 18f301b09f..0000000000 --- a/gtk/tests/test_implementation.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "toga_gtk") - ) - ) -) diff --git a/gtk/tests/widgets/__init__.py b/gtk/tests/widgets/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/iOS/tests/__init__.py b/iOS/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/iOS/tests/test_implementation.py b/iOS/tests/test_implementation.py deleted file mode 100644 index bb39231812..0000000000 --- a/iOS/tests/test_implementation.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "toga_iOS") - ) - ) -) diff --git a/tox.ini b/tox.ini index 308b6dc1f6..1e53bc5647 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,14 @@ # The leading comma generates the "py-..." environments. -[testenv:py{,38,39,310,311,312}-{android,cocoa,core,gtk,iOS,web,winforms}] +[testenv:py{,38,39,310,311,312}] skip_install = True setenv = - android: subdir = android - cocoa: subdir = cocoa - core: subdir = core - gtk: subdir = gtk - iOS: subdir = iOS - web: subdir = web - winforms: subdir = winforms - - core: TOGA_BACKEND = toga_dummy - !core: TOGA_BACKEND = toga_{env:subdir} - gtk: test_command_prefix = xvfb-run -a -s "-screen 0 2048x1536x24" -changedir = {env:subdir} + TOGA_BACKEND = toga_dummy +changedir = core allowlist_externals = bash - gtk: xvfb-run commands = # TOGA_INSTALL_COMMAND is set to a bash command by the CI workflow. - {env:TOGA_INSTALL_COMMAND:python -m pip install ../core[dev] ../dummy .} + {env:TOGA_INSTALL_COMMAND:python -m pip install .[dev] ../dummy} {env:test_command_prefix:} coverage run -m pytest -vv {posargs} coverage combine coverage report --rcfile ../pyproject.toml diff --git a/web/tests/__init__.py b/web/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/web/tests/test_implementation.py b/web/tests/test_implementation.py deleted file mode 100644 index 51631ff090..0000000000 --- a/web/tests/test_implementation.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "toga_web") - ) - ) -) diff --git a/winforms/tests/__init__.py b/winforms/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/winforms/tests/test_implementation.py b/winforms/tests/test_implementation.py deleted file mode 100644 index e8df0c4e38..0000000000 --- a/winforms/tests/test_implementation.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "src", "toga_winforms" - ) - ) - ) -) From 98ef170939e84fa3b37eccb52cbffadac0a58dcf Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Oct 2023 13:12:25 +0800 Subject: [PATCH 26/66] Update docs to remove references to py-core and backend tests. --- docs/how-to/contribute-code.rst | 162 ++++++++++---------------------- docs/how-to/contribute-docs.rst | 7 ++ 2 files changed, 59 insertions(+), 110 deletions(-) diff --git a/docs/how-to/contribute-code.rst b/docs/how-to/contribute-code.rst index 5006e2d697..c1a3d7f1e2 100644 --- a/docs/how-to/contribute-code.rst +++ b/docs/how-to/contribute-code.rst @@ -356,25 +356,6 @@ What should I do? Depending on your level of expertise, or areas of interest, there are a number of ways you can contribute to Toga's code. -Improve test coverage for the core API --------------------------------------- - -If this is your first time contributing, this is probably the easiest place to -start. - -Toga has a test suite that verifies that the public API behaves as expected. -This API is tested against a "dummy" backend - a backend that implements the -same API as the platform backends (e.g., ``toga-cocoa`` and ``toga-winforms``), -but without relying on any specific platform graphical behavior. The dummy -backend mocks the behavior of a real backend, and provides additional properties -to verify when various actions have been performed on the backend. - -We want to get our core API 100% coverage, but we're not there yet - and you can -help! Your task: create a test that improves coverage - even by one more line. - -Details on how to run the test suite and check coverage :ref:`can be found below -`. - Fix a bug in an existing widget ------------------------------- @@ -408,17 +389,16 @@ information provided by the reporter, and trying to reproduce it. Again, if you can't reproduce the problem, report what you have found as a comment on the ticket, and pick another ticket. -If you can reproduce the problem - try to fix it! Work out what combination of -core and backend-specific code is implementing the feature, and see if you can -work out what isn't working correctly. You may need to refer to platform -specific documentation (e.g., the `Cocoa AppKit -`__, `iOS UIKit -`__, `GTK +If you can reproduce the problem - try to fix it! Work out what combination of core and +backend-specific code is implementing the feature, and see if you can work out what +isn't working correctly. You may need to refer to platform specific documentation (e.g., +the `Cocoa AppKit `__, +`iOS UIKit `__, `GTK `__, `Winforms `__, -`Android `__ or `Shoelace -`__ API documentation) to work out why a widget isn't -behaving as expected. +`Android `__, `Shoelace +`__ or `Textual `__ API +documentation) to work out why a widget isn't behaving as expected. If you're able to fix the problem, you'll need to add tests for :ref:`the core API ` and/or :ref:`the testbed backend ` for @@ -431,27 +411,6 @@ the fix, that knowledge will often be enough for someone who knows more about a platform to solve the problem. Even a good reproduction case (a sample app that does nothing but reproduce the problem) can be a huge help. -Convert from ``unittest`` to pytest ------------------------------------ - -Toga's test suite was historically written using Python's builtin ``unittest`` -library. We're currently porting these old tests to pytest. Pick a widget that -has ``unittest``-based tests, and port those tests over to pytest format. As you -do this, make sure the test makes good use of pytest features (like fixtures and -parameterization). The tests that have been already been ported to pytest are a -good reference for what a good Toga pytest looks like. - -Improve test coverage for a backend API ---------------------------------------- - -If you've got expertise in a particular platform (for example, if you've got -experience writing iOS apps), or you'd *like* to have that experience, you might -want to look into writing tests for a platform backend. We want to get to 100% -coverage for all the backend APIs, but we're a long way from that goal. - -The platform backends are tested using a testbed app. Details on how to run the -testbed app for a given platform :ref:`can be found below `. - Contribute improvements to documentation ---------------------------------------- @@ -463,19 +422,21 @@ on. Implement a platform native widget ---------------------------------- -If the core library already specifies an interface for a widget, but the widget -isn't implemented on your platform of choice, implement that interface. The -:doc:`supported widgets by platform ` table can -show you the widgets that are missing on various platforms. You can also look -for log messages in a running app (or the direct ``factory.not_implemented()`` -function calls that produce those log messages). At present, the web backend -has a lot of missing widgets, so if you have web skills, or would like to learn -more about `PyScript `__ and `Shoelace -`__, this could be a good place to contribute. +If the core library already specifies an interface for a widget, but the widget isn't +implemented on your platform of choice, implement that interface. The :doc:`supported +widgets by platform ` table can show you the widgets +that are missing on various platforms. You can also look for log messages in a running +app (or the direct ``factory.not_implemented()`` function calls that produce those log +messages). At present, the Web and Textual backends have the most missing widgets. If +you have web skills, or would like to learn more about `PyScript +`__ and `Shoelace `__, the web backend +could be a good place to contribute; if you'd like to learn more about terminal +applications and the or `Textual `__ API, contributing to +the Textual backend could be a good place for you to contribute. Alternatively, if there's a widget that doesn't exist, propose an interface design, and implement it for at least one platform. You may find `this -presentation by BeeWare team member Dan Yeaw +presentation by BeeWare emeritus team member Dan Yeaw `__ helpful. This talk gives an architectural overview of Toga, as well as providing a guide to the process of adding new widgets. @@ -499,11 +460,10 @@ you add. Implement an entirely new platform backend ------------------------------------------ -Toga currently has support for 6 backends - but there's room for more! In +Toga currently has support for 7 backends - but there's room for more! In particular, we'd be interested in seeing a `Qt-based backend `__ to support KDE-based Linux -desktops, and a `Textual-based console backend -`__. +desktops. The first steps of any new platform backend are always the same: @@ -550,19 +510,19 @@ To run the core test suite: .. code-block:: console - (venv) $ tox -e py-core + (venv) $ tox -e py .. group-tab:: Linux .. code-block:: console - (venv) $ tox -e py-core + (venv) $ tox -e py .. group-tab:: Windows .. code-block:: doscon - (venv) C:\...>tox -e py-core + (venv) C:\...>tox -e py You should get some output indicating that tests have been run. You may see ``SKIPPED`` tests, but shouldn't ever get any ``FAIL`` or ``ERROR`` test @@ -571,51 +531,31 @@ discovers any problems, we don't merge the patch. If you do find a test error or failure, either there's something odd in your test environment, or you've found an edge case that we haven't seen before - either way, let us know! -Although the tests should all pass, the test suite itself is still incomplete. -There are many aspects of the Toga Core API that aren't currently tested (or -aren't tested thoroughly). To work out what *isn't* tested, Toga uses a tool -called `coverage `__. Coverage allows -you to check which lines of code have (and haven't) been executed - which then -gives you an idea of what code has (and hasn't) been tested. - At the end of the test output there should be a report of the coverage data that was gathered:: - Name Stmts Miss Cover Missing - ------------------------------------------------------------------ - toga/__init__.py 29 0 100% - toga/app.py 50 0 100% - ... - toga/window.py 79 18 77% 58, 75, 87, 92, 104, 141, 155, 164, 168, 172-173, 176, 192, 204, 216, 228, 243, 257 - ------------------------------------------------------------------ - TOTAL 1034 258 75% - -What does this all mean? Well, the "Cover" column tells you what proportion of -lines in a given file were executed during the test run. In this run, every -line of ``toga/app.py`` was executed; but only 77% of lines in -``toga/window.py`` were executed. Which lines were missed? They're listed in -the next column: lines 58, 75, 87, and so on weren't executed. - -Ideally, every single line in every single file will have 100% coverage. If you -look in `core/tests`, you should find a test file that matches the name of the -file that has insufficient coverage. If you don't, it's possible the entire test -file is missing - so you'll have to create it! - -Once you've written a test, re-run the test suite to generate fresh coverage -data. Let's say we added a test for line 58 of ``toga/window.py`` - we'd -expect to see something like:: - - Name Stmts Miss Cover Missing - ------------------------------------------------------------------ - toga/__init__.py 29 0 100% - toga/app.py 50 0 100% - ... - toga/window.py 79 17 78% 75, 87, 92, 104, 141, 155, 164, 168, 172-173, 176, 192, 204, 216, 228, 243, 257 - ------------------------------------------------------------------ - TOTAL 1034 257 75% - -That is, one more test has been executed, resulting in one less missing line -in the coverage results. + Name Stmts Miss Branch BrPart Cover Missing + ---------------------------------------------------- + TOTAL 4345 0 1040 0 100.0% + +This tells us that the test suite has executed every possible branching path +in the ``toga-core`` library. This isn't a 100% guarantee that there are no bugs, +but it does mean that we're exercising every line of code in the core API. + +If you make changes to the core API, it's possible you'll introduce a gap in this +coverage. When this happens, the coverage report will tell you which lines aren't +being executed. For example, lets say we made a change to ``toga/window.py``, +adding some new logic. The coverage report might look something like:: + + Name Stmts Miss Branch BrPart Cover Missing + ---------------------------------------------------------------- + src/toga/window.py 186 2 22 2 98.1% 211, 238-240 + ---------------------------------------------------------------- + TOTAL 4345 2 1040 2 99.9% + +This tells us that line 211, and lines 238-240 are not being executed by the test +suite. You'll need to add new tests (or modify an existing test) to restore this +coverage. When you're developing your new test, it may be helpful to run *just* that one test. To do this, you can pass in the name of a specific test file (or a @@ -628,19 +568,19 @@ specific test, using `pytest specifiers .. code-block:: console - (venv) $ tox -e py-core -- tests/path_to_test_file/test_some_test.py + (venv) $ tox -e py -- tests/path_to_test_file/test_some_test.py .. group-tab:: Linux .. code-block:: console - (venv) $ tox -e py-core -- tests/path_to_test_file/test_some_test.py + (venv) $ tox -e py -- tests/path_to_test_file/test_some_test.py .. group-tab:: Windows .. code-block:: doscon - (venv) C:\...>tox -e py-core -- tests/path_to_test_file/test_some_test.py + (venv) C:\...>tox -e py -- tests/path_to_test_file/test_some_test.py These test paths are relative to the ``core`` directory. You'll still get a coverage report when running a part of the test suite - but the coverage results @@ -836,6 +776,8 @@ result, the Cocoa implementation of the ``color`` `property of the Button probe `__ performs an ``xfail`` describing that limitation. +.. _pr-housekeeping: + Submitting a pull request ========================= diff --git a/docs/how-to/contribute-docs.rst b/docs/how-to/contribute-docs.rst index 755818c42a..1654c5df5d 100644 --- a/docs/how-to/contribute-docs.rst +++ b/docs/how-to/contribute-docs.rst @@ -174,3 +174,10 @@ However, you don't need to be constrained by these tickets. If you can identify a gap in Toga's documentation, or an improvement that can be made, start writing! Anything that improves the experience of the end user is a welcome change. + +Submitting a pull request +========================= + +Before you submit a pull request, there's a few bits of housekeeping to do. See the +section on submitting a pull request in the :ref:`code contribution guide +` for details on our submission process. From eea80a1ff9ababa5224d1f7f9d255782cc5c20c5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Oct 2023 13:15:34 +0800 Subject: [PATCH 27/66] Correct some pre-commit issues. --- android/src/toga_android/colors.py | 3 +-- android/src/toga_android/dialogs.py | 3 +-- android/src/toga_android/fonts.py | 4 ++-- android/src/toga_android/images.py | 3 +-- android/src/toga_android/keys.py | 1 + android/src/toga_android/libs/events.py | 3 +-- android/src/toga_android/widgets/base.py | 6 +++--- android/src/toga_android/widgets/box.py | 3 +-- android/src/toga_android/widgets/button.py | 5 ++--- android/src/toga_android/widgets/canvas.py | 10 +++++----- android/src/toga_android/widgets/dateinput.py | 3 +-- android/src/toga_android/widgets/detailedlist.py | 7 +++---- android/src/toga_android/widgets/imageview.py | 1 + android/src/toga_android/widgets/internal/pickers.py | 5 ++--- android/src/toga_android/widgets/label.py | 4 ++-- android/src/toga_android/widgets/multilinetextinput.py | 3 +-- android/src/toga_android/widgets/numberinput.py | 1 + android/src/toga_android/widgets/progressbar.py | 3 +-- android/src/toga_android/widgets/scrollcontainer.py | 5 ++--- android/src/toga_android/widgets/selection.py | 5 ++--- android/src/toga_android/widgets/slider.py | 6 +++--- android/src/toga_android/widgets/switch.py | 5 ++--- android/src/toga_android/widgets/table.py | 8 ++++---- android/src/toga_android/widgets/textinput.py | 6 +++--- android/src/toga_android/widgets/timeinput.py | 3 +-- android/src/toga_android/widgets/webview.py | 2 +- android/src/toga_android/window.py | 3 +-- android/tests_backend/fonts.py | 6 +++--- android/tests_backend/icons.py | 1 - android/tests_backend/probe.py | 5 ++--- android/tests_backend/widgets/canvas.py | 5 ++--- android/tests_backend/widgets/detailedlist.py | 3 +-- android/tests_backend/widgets/table.py | 1 - core/tests/command/test_group.py | 2 +- 34 files changed, 58 insertions(+), 76 deletions(-) diff --git a/android/src/toga_android/colors.py b/android/src/toga_android/colors.py index 971ef9ea1b..aaedc9d77a 100644 --- a/android/src/toga_android/colors.py +++ b/android/src/toga_android/colors.py @@ -1,6 +1,5 @@ -from travertino.colors import NAMED_COLOR, TRANSPARENT - from android.graphics import Color +from travertino.colors import NAMED_COLOR, TRANSPARENT CACHE = {TRANSPARENT: Color.TRANSPARENT} diff --git a/android/src/toga_android/dialogs.py b/android/src/toga_android/dialogs.py index ab8c0abdf1..f367594345 100644 --- a/android/src/toga_android/dialogs.py +++ b/android/src/toga_android/dialogs.py @@ -1,10 +1,9 @@ from abc import ABC -from java import dynamic_proxy - from android import R from android.app import AlertDialog from android.content import DialogInterface +from java import dynamic_proxy class OnClickListener(dynamic_proxy(DialogInterface.OnClickListener)): diff --git a/android/src/toga_android/fonts.py b/android/src/toga_android/fonts.py index 677343115f..f3b7018564 100644 --- a/android/src/toga_android/fonts.py +++ b/android/src/toga_android/fonts.py @@ -1,10 +1,10 @@ from pathlib import Path -from org.beeware.android import MainActivity - from android import R from android.graphics import Typeface from android.util import TypedValue +from org.beeware.android import MainActivity + from toga.fonts import ( _REGISTERED_FONT_CACHE, BOLD, diff --git a/android/src/toga_android/images.py b/android/src/toga_android/images.py index e3a357e57f..7b736e7e0d 100644 --- a/android/src/toga_android/images.py +++ b/android/src/toga_android/images.py @@ -1,8 +1,7 @@ from pathlib import Path -from java.io import FileOutputStream - from android.graphics import Bitmap, BitmapFactory +from java.io import FileOutputStream class Image: diff --git a/android/src/toga_android/keys.py b/android/src/toga_android/keys.py index db1407f57b..410b758559 100644 --- a/android/src/toga_android/keys.py +++ b/android/src/toga_android/keys.py @@ -1,4 +1,5 @@ from android.view import KeyEvent + from toga.keys import Key KEYEVENT_KEYS = { diff --git a/android/src/toga_android/libs/events.py b/android/src/toga_android/libs/events.py index 2e1a3c16f9..0a225b14e1 100644 --- a/android/src/toga_android/libs/events.py +++ b/android/src/toga_android/libs/events.py @@ -7,12 +7,11 @@ import sys import threading +from android.os import Handler, Looper, MessageQueue from java import dynamic_proxy from java.io import FileDescriptor from java.lang import Runnable -from android.os import Handler, Looper, MessageQueue - # Some methods in this file are based on CPython's implementation. # Per https://github.com/python/cpython/blob/master/LICENSE , re-use is permitted # via the Python Software Foundation License Version 2, which includes inclusion diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 9fae28706f..ea766b2dec 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -1,13 +1,13 @@ from abc import ABC, abstractmethod from decimal import ROUND_HALF_EVEN, ROUND_UP, Decimal -from org.beeware.android import MainActivity -from travertino.size import at_least - from android.graphics import PorterDuff, PorterDuffColorFilter, Rect from android.graphics.drawable import ColorDrawable, InsetDrawable from android.view import Gravity, View from android.widget import RelativeLayout +from org.beeware.android import MainActivity +from travertino.size import at_least + from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT, TRANSPARENT from ..colors import native_color diff --git a/android/src/toga_android/widgets/box.py b/android/src/toga_android/widgets/box.py index 2a584a130f..dd9974e677 100644 --- a/android/src/toga_android/widgets/box.py +++ b/android/src/toga_android/widgets/box.py @@ -1,6 +1,5 @@ -from travertino.size import at_least - from android.widget import RelativeLayout +from travertino.size import at_least from .base import Widget diff --git a/android/src/toga_android/widgets/button.py b/android/src/toga_android/widgets/button.py index ccc167fc5c..5a877e696b 100644 --- a/android/src/toga_android/widgets/button.py +++ b/android/src/toga_android/widgets/button.py @@ -1,8 +1,7 @@ -from java import dynamic_proxy -from travertino.size import at_least - from android.view import View from android.widget import Button as A_Button +from java import dynamic_proxy +from travertino.size import at_least from .label import TextViewWidget diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 47cbb4508d..ff62cf2a3e 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -1,10 +1,5 @@ from math import degrees, pi -from java import dynamic_proxy, jint -from java.io import ByteArrayOutputStream -from org.beeware.android import DrawHandlerView, IDrawHandler -from travertino.size import at_least - from android.graphics import ( Bitmap, Canvas as A_Canvas, @@ -14,6 +9,11 @@ Path, ) from android.view import MotionEvent, View +from java import dynamic_proxy, jint +from java.io import ByteArrayOutputStream +from org.beeware.android import DrawHandlerView, IDrawHandler +from travertino.size import at_least + from toga.widgets.canvas import Baseline, FillRule from ..colors import native_color diff --git a/android/src/toga_android/widgets/dateinput.py b/android/src/toga_android/widgets/dateinput.py index 2f3e000c4e..bd0e161425 100644 --- a/android/src/toga_android/widgets/dateinput.py +++ b/android/src/toga_android/widgets/dateinput.py @@ -1,9 +1,8 @@ from datetime import date, datetime, time -from java import dynamic_proxy - from android import R from android.app import DatePickerDialog +from java import dynamic_proxy from .internal.pickers import PickerBase diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index bdcc487eb8..d7ffbe07a8 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -1,15 +1,14 @@ from dataclasses import dataclass -from androidx.swiperefreshlayout.widget import SwipeRefreshLayout -from java import dynamic_proxy -from travertino.size import at_least - from android import R from android.app import AlertDialog from android.content import DialogInterface from android.graphics import Rect from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView +from androidx.swiperefreshlayout.widget import SwipeRefreshLayout +from java import dynamic_proxy +from travertino.size import at_least from .base import Widget diff --git a/android/src/toga_android/widgets/imageview.py b/android/src/toga_android/widgets/imageview.py index e70bd38392..77e999d048 100644 --- a/android/src/toga_android/widgets/imageview.py +++ b/android/src/toga_android/widgets/imageview.py @@ -1,4 +1,5 @@ from android.widget import ImageView as A_ImageView + from toga.widgets.imageview import rehint_imageview from .base import Widget diff --git a/android/src/toga_android/widgets/internal/pickers.py b/android/src/toga_android/widgets/internal/pickers.py index 470f1b05e5..7f74644177 100644 --- a/android/src/toga_android/widgets/internal/pickers.py +++ b/android/src/toga_android/widgets/internal/pickers.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from java import dynamic_proxy -from travertino.size import at_least - from android.view import View from android.widget import EditText +from java import dynamic_proxy +from travertino.size import at_least from ..label import TextViewWidget diff --git a/android/src/toga_android/widgets/label.py b/android/src/toga_android/widgets/label.py index 756fa51e0c..60ebf5528b 100644 --- a/android/src/toga_android/widgets/label.py +++ b/android/src/toga_android/widgets/label.py @@ -1,10 +1,10 @@ -from travertino.size import at_least - from android.os import Build from android.text import Layout from android.util import TypedValue from android.view import Gravity, View from android.widget import TextView +from travertino.size import at_least + from toga.constants import JUSTIFY from toga_android.colors import native_color diff --git a/android/src/toga_android/widgets/multilinetextinput.py b/android/src/toga_android/widgets/multilinetextinput.py index a3ee4a1591..a69599cdf1 100644 --- a/android/src/toga_android/widgets/multilinetextinput.py +++ b/android/src/toga_android/widgets/multilinetextinput.py @@ -1,7 +1,6 @@ -from travertino.size import at_least - from android.text import InputType from android.view import Gravity +from travertino.size import at_least from .textinput import TextInput diff --git a/android/src/toga_android/widgets/numberinput.py b/android/src/toga_android/widgets/numberinput.py index e91050e8e2..9d439382d5 100644 --- a/android/src/toga_android/widgets/numberinput.py +++ b/android/src/toga_android/widgets/numberinput.py @@ -1,6 +1,7 @@ from decimal import InvalidOperation from android.text import InputType + from toga.widgets.numberinput import _clean_decimal from .textinput import TextInput diff --git a/android/src/toga_android/widgets/progressbar.py b/android/src/toga_android/widgets/progressbar.py index 539f1ce1d6..7851f635b8 100644 --- a/android/src/toga_android/widgets/progressbar.py +++ b/android/src/toga_android/widgets/progressbar.py @@ -1,8 +1,7 @@ -from travertino.size import at_least - from android import R from android.view import View from android.widget import ProgressBar as A_ProgressBar +from travertino.size import at_least from .base import Widget diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index 1e727774f4..2fb5cbfe80 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -1,10 +1,9 @@ from decimal import ROUND_DOWN -from java import dynamic_proxy -from travertino.size import at_least - from android.view import Gravity, View from android.widget import HorizontalScrollView, LinearLayout, ScrollView +from java import dynamic_proxy +from travertino.size import at_least from ..container import Container from .base import Widget diff --git a/android/src/toga_android/widgets/selection.py b/android/src/toga_android/widgets/selection.py index 2e79e93382..b2fc9bb2af 100644 --- a/android/src/toga_android/widgets/selection.py +++ b/android/src/toga_android/widgets/selection.py @@ -1,9 +1,8 @@ -from java import dynamic_proxy -from travertino.size import at_least - from android import R from android.view import View from android.widget import AdapterView, ArrayAdapter, Spinner +from java import dynamic_proxy +from travertino.size import at_least from .base import Widget diff --git a/android/src/toga_android/widgets/slider.py b/android/src/toga_android/widgets/slider.py index 6f75168977..3dfd5a6afc 100644 --- a/android/src/toga_android/widgets/slider.py +++ b/android/src/toga_android/widgets/slider.py @@ -1,10 +1,10 @@ +from android import R +from android.view import View +from android.widget import SeekBar from java import dynamic_proxy from travertino.size import at_least import toga -from android import R -from android.view import View -from android.widget import SeekBar from .base import Widget diff --git a/android/src/toga_android/widgets/switch.py b/android/src/toga_android/widgets/switch.py index e843cd94bd..455be11e1b 100644 --- a/android/src/toga_android/widgets/switch.py +++ b/android/src/toga_android/widgets/switch.py @@ -1,8 +1,7 @@ -from java import dynamic_proxy -from travertino.size import at_least - from android.view import View from android.widget import CompoundButton, Switch as A_Switch +from java import dynamic_proxy +from travertino.size import at_least from .label import TextViewWidget diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 849addeff0..5cf759d56c 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -1,13 +1,13 @@ from warnings import warn -from java import dynamic_proxy -from travertino.size import at_least - -import toga from android import R from android.graphics import Rect, Typeface from android.view import Gravity, View from android.widget import LinearLayout, ScrollView, TableLayout, TableRow, TextView +from java import dynamic_proxy +from travertino.size import at_least + +import toga from .base import Widget from .label import set_textview_font diff --git a/android/src/toga_android/widgets/textinput.py b/android/src/toga_android/widgets/textinput.py index 24d1529026..c36a264654 100644 --- a/android/src/toga_android/widgets/textinput.py +++ b/android/src/toga_android/widgets/textinput.py @@ -1,9 +1,9 @@ -from java import dynamic_proxy -from travertino.size import at_least - from android.text import InputType, TextWatcher from android.view import Gravity, View from android.widget import EditText +from java import dynamic_proxy +from travertino.size import at_least + from toga_android.keys import toga_key from .label import TextViewWidget diff --git a/android/src/toga_android/widgets/timeinput.py b/android/src/toga_android/widgets/timeinput.py index 05d9744ae9..db8b86357f 100644 --- a/android/src/toga_android/widgets/timeinput.py +++ b/android/src/toga_android/widgets/timeinput.py @@ -1,9 +1,8 @@ from datetime import time -from java import dynamic_proxy - from android import R from android.app import TimePickerDialog +from java import dynamic_proxy from .internal.pickers import PickerBase diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 343fadd954..10306234aa 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -1,9 +1,9 @@ import json +from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from java import dynamic_proxy from travertino.size import at_least -from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from toga.widgets.webview import JavaScriptResult from .base import Widget diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 88fc0fd77b..48e9c10cf2 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -1,9 +1,8 @@ from decimal import ROUND_UP -from java import dynamic_proxy - from android import R from android.view import ViewTreeObserver +from java import dynamic_proxy from .container import Container diff --git a/android/tests_backend/fonts.py b/android/tests_backend/fonts.py index 63014f7875..dc8aa96b6f 100644 --- a/android/tests_backend/fonts.py +++ b/android/tests_backend/fonts.py @@ -1,12 +1,12 @@ from concurrent.futures import ThreadPoolExecutor +from android.graphics import Typeface +from android.graphics.fonts import FontFamily +from android.util import TypedValue from fontTools.ttLib import TTFont from java import jint from java.lang import Integer, Long -from android.graphics import Typeface -from android.graphics.fonts import FontFamily -from android.util import TypedValue from toga.fonts import ( BOLD, ITALIC, diff --git a/android/tests_backend/icons.py b/android/tests_backend/icons.py index 7e09968550..c436051a80 100644 --- a/android/tests_backend/icons.py +++ b/android/tests_backend/icons.py @@ -1,5 +1,4 @@ import pytest - from android.graphics import Bitmap from .probe import BaseProbe diff --git a/android/tests_backend/probe.py b/android/tests_backend/probe.py index fc8c458e79..4b9ded8ba1 100644 --- a/android/tests_backend/probe.py +++ b/android/tests_backend/probe.py @@ -1,11 +1,10 @@ import asyncio -from java import dynamic_proxy -from org.beeware.android import MainActivity - from android import R from android.view import View, ViewTreeObserver, WindowManagerGlobal from android.widget import Button +from java import dynamic_proxy +from org.beeware.android import MainActivity class LayoutListener(dynamic_proxy(ViewTreeObserver.OnGlobalLayoutListener)): diff --git a/android/tests_backend/widgets/canvas.py b/android/tests_backend/widgets/canvas.py index 380bc9c3f2..84259bb310 100644 --- a/android/tests_backend/widgets/canvas.py +++ b/android/tests_backend/widgets/canvas.py @@ -1,11 +1,10 @@ from io import BytesIO import pytest -from org.beeware.android import DrawHandlerView -from PIL import Image - from android.os import SystemClock from android.view import MotionEvent +from org.beeware.android import DrawHandlerView +from PIL import Image from .base import SimpleProbe diff --git a/android/tests_backend/widgets/detailedlist.py b/android/tests_backend/widgets/detailedlist.py index 2921e14be2..2e42c7b28e 100644 --- a/android/tests_backend/widgets/detailedlist.py +++ b/android/tests_backend/widgets/detailedlist.py @@ -1,7 +1,5 @@ import asyncio -from androidx.swiperefreshlayout.widget import SwipeRefreshLayout - from android.os import SystemClock from android.view import KeyEvent from android.widget import ( @@ -12,6 +10,7 @@ ScrollView, TextView, ) +from androidx.swiperefreshlayout.widget import SwipeRefreshLayout from .base import SimpleProbe, find_view_by_type diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index d27084314f..aea21ed8a0 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -1,5 +1,4 @@ import pytest - from android.widget import ScrollView, TableLayout, TextView from .base import SimpleProbe diff --git a/core/tests/command/test_group.py b/core/tests/command/test_group.py index 3b2e29ec8c..a80b839e61 100644 --- a/core/tests/command/test_group.py +++ b/core/tests/command/test_group.py @@ -66,7 +66,7 @@ def test_hashable(): def test_group_eq(): - """Groups can be comared for equality.""" + """Groups can be compared for equality.""" group_a = toga.Group("A") group_b = toga.Group("B") group_a1 = toga.Group("A", 1) From 7f42fce124c52023d255d5e7602b47c0f051d41d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Oct 2023 13:42:55 +0800 Subject: [PATCH 28/66] Tweaks for GTK test coverage. --- gtk/src/toga_gtk/window.py | 2 +- gtk/tests_backend/window.py | 2 +- testbed/tests/test_app.py | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 1ef6ff93b1..2d00985c0b 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -87,7 +87,7 @@ def create_toolbar(self): item_impl = Gtk.ToolButton() if cmd.icon: item_impl.set_icon_widget( - Gtk.Image.new_from_pixbuf(cmd.icon._impl.native_32.get_pixbuf()) + Gtk.Image.new_from_pixbuf(cmd.icon._impl.native_32) ) item_impl.set_label(cmd.text) if cmd.tooltip: diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index d638eb2760..a90b60f8ef 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -25,7 +25,7 @@ def __init__(self, app, window): assert isinstance(self.native, Gtk.Window) async def wait_for_window(self, message, minimize=False, full_screen=False): - await self.redraw(message, delay=0.5 if full_screen or minimize else 0.1) + await self.redraw(message, delay=0.5 if (full_screen or minimize) else 0.1) def close(self): if self.is_closable: diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 6d71eaa839..de443766f7 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -317,21 +317,22 @@ async def test_current_window(app, app_probe): window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - window2_probe = window_probe(app, window2) - window3_probe = window_probe(app, window2) + # We don't need to probe anything window specific; we just need + # a window probe to enforce appropriate delays. + window1_probe = window_probe(app, window1) window1.show() window2.show() window3.show() - await app_probe.redraw("Extra windows added") + await window1_probe.wait_for_window("Extra windows added") app.current_window = window2 - await window2_probe.wait_for_window("Window 2 is current") + await window1_probe.wait_for_window("Window 2 is current") assert app.current_window == window2 app.current_window = window3 - await window3_probe.wait_for_window("Window 3 is current") + await window1_probe.wait_for_window("Window 3 is current") assert app.current_window == window3 # app_probe.platform tests? From 69e760b6d707871215b0ea2cf6cdc3dc383e7449 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 19 Oct 2023 14:51:01 +0800 Subject: [PATCH 29/66] Ensure constraints are retained until the container is destroyed. --- cocoa/src/toga_cocoa/container.py | 8 ++++++-- cocoa/src/toga_cocoa/window.py | 8 ++++++-- testbed/tests/test_app.py | 5 +++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cocoa/src/toga_cocoa/container.py b/cocoa/src/toga_cocoa/container.py index 8cef990686..383abee054 100644 --- a/cocoa/src/toga_cocoa/container.py +++ b/cocoa/src/toga_cocoa/container.py @@ -67,7 +67,7 @@ def __init__( NSLayoutAttributeLeft, 1.0, min_width, - ) + ).retain() self.native.addConstraint(self._min_width_constraint) self._min_height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -78,9 +78,13 @@ def __init__( NSLayoutAttributeTop, 1.0, min_height, - ) + ).retain() self.native.addConstraint(self._min_height_constraint) + def __del__(self): + self._min_height_constraint.release() + self._min_width_constraint.release() + @property def content(self): """The Toga implementation widget that is the root content of this container. diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 1bcfab8425..0f3f509923 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -95,7 +95,10 @@ def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( @objc_method def validateToolbarItem_(self, item) -> bool: """Confirm if the toolbar item should be enabled.""" - return self.impl._toolbar_items[str(item.itemIdentifier)].enabled + try: + return self.impl._toolbar_items[str(item.itemIdentifier)].enabled + except KeyError: + return False ###################################################################### # Toolbar button press delegate methods @@ -136,7 +139,8 @@ def __init__(self, interface, title, position, size): # Cocoa releases windows when they are closed; this causes havoc with # Toga's widget cleanup because the ObjC runtime thinks there's no - # references to the object left. Add an explicit reference to the window. + # references to the object left. Add a reference that can be released + # in response to the close. self.native.retain() self.set_title(title) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index de443766f7..085db28f95 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -131,11 +131,12 @@ async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): await app_probe.redraw("Extra windows added") app_probe.activate_menu_close_window() - assert window2 not in app.windows - await app_probe.redraw("Window 2 closed") + assert window2 not in app.windows + app_probe.activate_menu_close_all_windows() + await app_probe.redraw("All windows closed") # Close all windows will attempt to close the main window as well. # This would be an app exit, but we can't allow that; so, the only From 08b5b33495c22333e7e884ef23e4f29f3fbd97b4 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 20 Oct 2023 14:24:58 +0800 Subject: [PATCH 30/66] Increase tolerances for testing on cocoa. --- cocoa/src/toga_cocoa/window.py | 6 +++++- cocoa/tests_backend/window.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 0f3f509923..03bdd2ca21 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -97,7 +97,11 @@ def validateToolbarItem_(self, item) -> bool: """Confirm if the toolbar item should be enabled.""" try: return self.impl._toolbar_items[str(item.itemIdentifier)].enabled - except KeyError: + except KeyError: # pragma: nocover + # This branch *shouldn't* ever happen; but there's an edge + # case where a toolbar redraw happens in the middle of deleting + # a toolbar item that can't be reliably reproduced, so it sometimes + # happens in testing. return False ###################################################################### diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index e31abfd4a1..7d6c997cb2 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -37,7 +37,7 @@ def __init__(self, app, window): async def wait_for_window(self, message, minimize=False, full_screen=False): await self.redraw( message, - delay=0.75 if full_screen else 0.5 if minimize else None, + delay=0.75 if full_screen else 0.5 if minimize else 0.1, ) def close(self): From fb25864759f51118b324dd380371e18fd04b36f4 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 23 Oct 2023 10:52:29 +0100 Subject: [PATCH 31/66] Remove toga_dummy/test_implementation.py --- dummy/src/toga_dummy/test_implementation.py | 456 -------------------- 1 file changed, 456 deletions(-) delete mode 100644 dummy/src/toga_dummy/test_implementation.py diff --git a/dummy/src/toga_dummy/test_implementation.py b/dummy/src/toga_dummy/test_implementation.py deleted file mode 100644 index fbd5600254..0000000000 --- a/dummy/src/toga_dummy/test_implementation.py +++ /dev/null @@ -1,456 +0,0 @@ -import ast -import importlib -import os -import unittest -from collections import defaultdict, namedtuple - -try: - # Usually, the pattern is "import module; if it doesn't exist, - # import the shim". However, we need the 3.10 API for entry_points, - # as the 3.8 didn't support the `groups` argument to entry_points. - # Therefore, we try to import the compatibility shim first; and fall - # back to the stdlib module if the shim isn't there. - from importlib_metadata import entry_points -except ImportError: - from importlib.metadata import entry_points - -from itertools import zip_longest -from os.path import join -from pathlib import Path - -import toga_dummy - - -class NoDefault: - """This utility class to indicate that no argument default exists. - - The use of `None` is not possible because it itself could be a default argument - value. - """ - - def __eq__(self, other): - if isinstance(other, NoDefault): - return True - else: - return False - - def __repr__(self): - return "no_default" - - -FunctionArguments = namedtuple( - "FunctionArguments", ["args", "vararg", "kwarg", "kwonlyargs"] -) - - -class DefinitionExtractor: - """The DefinitionExtractor consumes a .py file and extracts information, with the - help of the 'ast' module from it. - - Non-existing files result in an empty DefinitionExtractor, this means the - all properties return empty lists or dicts. - - Args: - path (str): The path to the .py file. - """ - - def __init__(self, path, platform_category=None): - self.exists = os.path.isfile(path) - self._classes = {} - self._methods = defaultdict(dict) - self.platform = platform_category if platform_category else None - - if self.exists: - # open the file and parse it with the ast module. - with open(path) as f: - lines = f.read() - self.tree = ast.parse(lines) - self._extract_file() - - def _extract_file(self): - self._extract_classes() - self._extract_class_methods() - - @property - def class_names(self): - return self._classes.keys() - - @property - def method_names(self): - return self._methods.keys() - - def _extract_classes(self): - for node in ast.walk(self.tree): - if isinstance(node, ast.ClassDef): - if self.is_required_for_platform(node): - self._classes[node.name] = node # use the class name as the key - elif isinstance(node, ast.Assign) and node.col_offset == 0: - # Allow a class with no new methods to be defined by assigning - # from an existing class. The col_offset means we only pay - # attention to assignments at the top level of a module, not - # assignments inside method bodies. - for target in node.targets: - if isinstance(target, ast.Name): - self._classes[target.id] = node - - def is_required_for_platform(self, node): - """Checks if the class or function is required for the given platform. It looks - for a decorator with the name `not_required_on`. - - Returns: - `True` if the class/function is required for the platform. - `False` if the class/function is not required and can be dropped for this platform. - """ - if node.decorator_list: # check if a decorator list exists - for decorator in node.decorator_list: - try: - # @not_required is a bare decorator, so the decorator node - # has an `id` attribute. - # @not_required_on is a decorator factory, so the decorator - # node contains a function that has an id. - if getattr(decorator, "id", None) == "not_required": - return False - elif decorator.func.id == "not_required_on": - platforms_to_skip = [arg.s for arg in decorator.args] - if self.platform.intersection(set(platforms_to_skip)): - return False - except Exception: - pass - return True - - @staticmethod - def _get_function_defaults(node, kwonlyargs=False): - if kwonlyargs: - to_extract = node.kw_defaults - else: - to_extract = node.defaults - - defaults = [] - for default in to_extract: - if isinstance(default, ast.NameConstant): - defaults.append(default.value) - elif isinstance(default, ast.Str): - defaults.append(default.s) - elif isinstance(default, ast.Num): - defaults.append(default.n) - elif isinstance(default, ast.Tuple) or isinstance(default, ast.List): - defaults.append(default.elts) - elif isinstance(default, ast.Call): - defaults.append(default.func) - elif isinstance(default, ast.Attribute): - defaults.append(default.value) - elif isinstance(default, ast.Name): - defaults.append(default.id) - else: - raise RuntimeWarning( - 'ast classes of type "{}" can not be handled at the moment. ' - "Please implement to make this warning disappear.".format(default) - ) - return defaults - - def _extract_class_methods(self): - """Extract all the methods from the classes and save them in `self.methods`. - - Use the combination of class and method name, like so: - `.` as the key. - """ - for class_name in self._classes: - for node in ast.walk(self._classes[class_name]): - if isinstance(node, ast.FunctionDef): - if self.is_required_for_platform(node): - function_id = f"{class_name}.{node.name}" - self._methods[function_id]["node"] = node - self._methods[function_id][ - "arguments" - ] = self._extract_function_signature(node) - - def _extract_function_signature(self, node): - for node in ast.walk(node): - if isinstance(node, ast.arguments): - # Extract positional arguments and possible default values. - args = [arg.arg for arg in node.args] - args_defaults = self._get_function_defaults(node) - # Extract kwonlyargs and defaults. - kwonlyargs = [arg.arg for arg in node.kwonlyargs] - kwonlyargs_defaults = self._get_function_defaults(node, kwonlyargs=True) - - # Combine arguments and their corresponding default values, - # if no default value exists fill it with a NoDefault object. - args_plus_defaults = list( - zip_longest( - reversed(args), reversed(args_defaults), fillvalue=NoDefault() - ) - ) - kwonlyargs_plus_defaults = list( - zip_longest( - reversed(kwonlyargs), - reversed(kwonlyargs_defaults), - fillvalue=NoDefault(), - ) - ) - - vararg = node.vararg.arg if node.vararg is not None else None - kwarg = node.kwarg.arg if node.kwarg is not None else None - - return FunctionArguments( - args=args_plus_defaults, - vararg=vararg, - kwarg=kwarg, - kwonlyargs=kwonlyargs_plus_defaults, - ) - - def get_function_def(self, function_id): - return self._methods[function_id] - - def methods_of_class(self, class_name): - """Get all methods names of a class. - - Args: - class_name(str): Name of the class to extract the methods - - Returns: - Returns a `List` of (str) with all methods names of the class. - - Warnings: - Does not return inherited methods. Only methods that are present in the class and the actual .py file. - """ - methods = [] - if self.exists: - if class_name in self._classes.keys(): - class_node = self._classes[class_name] - for node in ast.walk(class_node): - if isinstance(node, ast.FunctionDef): - if self.is_required_for_platform( - node - ) and not node.name.startswith("simulate_"): - methods.append(node.name) - return methods - - -def get_platform_category(path_to_backend): - backend_name = os.path.basename(path_to_backend) - importlib.import_module(backend_name) - platform = {ep.value: ep.name for ep in entry_points(group="toga.backends")}[ - backend_name - ] - - return { - # Desktop - "macOS": {"desktop", backend_name.split("_")[-1]}, - "windows": {"desktop", backend_name.split("_")[-1]}, - "linux": {"desktop", backend_name.split("_")[-1]}, - # Mobile - "iOS": {"mobile", backend_name.split("_")[-1]}, - "android": {"mobile", backend_name.split("_")[-1]}, - }.get(platform, {platform, backend_name.split("_")[-1]}) - - -def get_required_files(platform_category, path_to_backend): - # Find the list of files in the dummy backend - # that aren't *this* file, or an __init__.py. - files = [ - str(p.relative_to(Path(__file__).parent)) - for p in Path(__file__).parent.rglob("**/*.py") - if str(p) != __file__ and p.name != "__init__.py" - ] - if "desktop" in platform_category: - for f in TOGA_DESKTOP_EXCLUDED_FILES: - files.remove(f) - if "mobile" in platform_category: - for f in TOGA_MOBILE_EXCLUDED_FILES: - files.remove(f) - if "web" in platform_category: - for f in TOGA_WEB_EXCLUDED_FILES: - files.remove(f) - if "console" in platform_category: - for f in TOGA_CONSOLE_EXCLUDED_FILES: - files.remove(f) - if "settop" in platform_category: - for f in TOGA_SETTOP_EXCLUDED_FILES: - files.remove(f) - if "watch" in platform_category: - for f in TOGA_WATCH_EXCLUDED_FILES: - files.remove(f) - - return files - - -def create_impl_tests(root): - """Calling this function with the path to a Toga backend will return the - implementation tests for this backend. - - Args: - root (str): The absolute path to a toga backend. - - Returns: - A dictionary of test classes. - """ - platform_category = get_platform_category(root) - dummy_files = collect_dummy_files(get_required_files(platform_category, root)) - tests = {} - for name, dummy_path in dummy_files: - if "widgets" in dummy_path: - path = os.path.join(root, f"widgets/{name}.py") - else: - path = os.path.join(root, f"{name}.py") - - tests.update(make_toga_impl_check_class(path, dummy_path, platform_category)) - return tests - - -TestFile = namedtuple("TestFile", ["name", "path"]) - - -def collect_dummy_files(required_files): - dummy_files = [] - toga_dummy_base = os.path.dirname(toga_dummy.__file__) - - for root, dirs, filenames in os.walk(toga_dummy_base): - for filename in filenames: - # exclude non .py filenames or start with '__' - if filename.startswith("__") or not filename.endswith(".py"): - continue - - full_filename = os.path.join(root, filename)[len(toga_dummy_base) + 1 :] - if full_filename in required_files: - f = TestFile(filename[:-3], os.path.join(root, filename)) - dummy_files.append(f) - - return dummy_files - - -def make_test_function(element, element_list, error_msg=None): - def fn(self): - self.assertIn(element, element_list, msg=error_msg if error_msg else fn.__doc__) - - return fn - - -def make_test_class(path, cls, expected, actual, skip): - class_name = f"{cls}ImplTest" - test_class = type(class_name, (unittest.TestCase,), {}) - - if skip: - test_class = unittest.skip(skip)(test_class) - - fn = make_test_function(cls, actual.class_names) - fn.__doc__ = ( - "Expect class {} to be defined in {}, to be consistent with dummy implementation" - ).format(cls, path) - test_class.test_class_exists = fn - - for method in expected.methods_of_class(cls): - # create a test that checks if the method exists in the class. - fn = make_test_function(method, actual.methods_of_class(cls)) - fn.__doc__ = f"The method {cls}.{method}(...) exists" - setattr(test_class, f"test_{method}_exists", fn) - - # create tests that check for the right method arguments. - method_id = f"{cls}.{method}" - method_def = expected.get_function_def(method_id)["arguments"] - try: - actual_method_def = actual.get_function_def(method_id)["arguments"] - except KeyError: - actual_method_def = None - - if actual_method_def: - # Create test whether the method takes the right arguments - # and if the arguments have the right name. - - # ARGS - for arg in method_def.args: - fn = make_test_function(arg, actual_method_def.args) - fn.__doc__ = "The argument {}.{}(..., {}={}, ...) exists".format( - cls, method, *arg - ) - setattr( - test_class, "test_{}_arg_{}_default_{}".format(method, *arg), fn - ) - - # *varargs - if method_def.vararg: - vararg = method_def.vararg - actual_vararg = ( - actual_method_def.vararg if actual_method_def.vararg else [] - ) - fn = make_test_function(vararg, actual_vararg) - fn.__doc__ = f"The vararg {cls}.{method}(..., *{vararg}, ...) exists" - setattr(test_class, f"test_{method}_vararg_{vararg}", fn) - - # **kwarg - if method_def.kwarg: - kwarg = method_def.kwarg - actual_kwarg = ( - actual_method_def.kwarg if actual_method_def.kwarg else [] - ) - fn = make_test_function( - kwarg, - actual_kwarg, - error_msg="The method does not take kwargs or the " - 'variable is not named "{}".'.format(kwarg), - ) - fn.__doc__ = ( - f"The kw argument {cls}.{method}(..., **{kwarg}, ...) exists" - ) - setattr(test_class, f"test_{method}_kw_{kwarg}", fn) - - # kwonlyargs - if method_def.kwonlyargs: - for kwonlyarg in method_def.kwonlyargs: - fn = make_test_function(kwonlyarg, actual_method_def.kwonlyargs) - fn.__doc__ = ( - "The kwonly argument {}.{}(..., {}={}, ...) exists".format( - cls, method, *kwonlyarg - ) - ) - setattr( - test_class, - "test_{}_kwonly_{}_default_{}".format(method, *kwonlyarg), - fn, - ) - - return class_name, test_class - - -def make_toga_impl_check_class(path, dummy_path, platform): - prefix = os.path.commonprefix([path, dummy_path]) - expected = DefinitionExtractor(dummy_path, platform) - if os.path.isfile(path): - skip = None - actual = DefinitionExtractor(path) - else: - skip = f"Implementation file {path[len(prefix):]} does not exist" - actual = DefinitionExtractor(path) - - test_classes = {} - - for cls in expected.class_names: - class_name, test_class = make_test_class( - path[len(prefix) :], cls, expected, actual, skip - ) - test_classes[class_name] = test_class - - return test_classes - - -# Files that do not need to be present in mobile implementations of Toga. -TOGA_MOBILE_EXCLUDED_FILES = [ - join("widgets", "splitcontainer.py"), -] - -# Files that do not need to be present in desktop implementations of Toga. -TOGA_DESKTOP_EXCLUDED_FILES = [] - -# Files do not need to be present in web implementations of Toga. -TOGA_WEB_EXCLUDED_FILES = [ - join("widgets", "splitcontainer.py"), -] - -# Files that do not need to be present in console implementations of Toga. -TOGA_CONSOLE_EXCLUDED_FILES = [] - -# Files that do not need to be present in set-top box implementations of Toga. -TOGA_SETTOP_EXCLUDED_FILES = [] - -# Files that do not need to be present in watch implementations of Toga. -TOGA_WATCH_EXCLUDED_FILES = [] From 72223446d609773cb591286ae55925868924ed1f Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 23 Oct 2023 10:52:57 +0100 Subject: [PATCH 32/66] Update tox and isort comments --- pyproject.toml | 3 +-- tox.ini | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7b39bb2c6c..cfd08b1383 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,7 @@ profile = "black" split_on_trailing_comma = true combine_as_imports = true known_third_party = [ - "android", - "java", + "android", # isort defaults to making this first-party for some reason. ] known_first_party = [ "testbed", diff --git a/tox.ini b/tox.ini index 1e53bc5647..f25cf8f399 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,4 @@ -# The leading comma generates the "py-..." environments. +# The leading comma generates the "py" environment. [testenv:py{,38,39,310,311,312}] skip_install = True setenv = From 3f67033edb369e292ff05710dde336d6447de571 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 23 Oct 2023 16:22:17 +0100 Subject: [PATCH 33/66] Clean up App constructor docs --- changes/2075.removal.5.rst | 1 + core/src/toga/app.py | 97 +++++++++++++++++++++----------------- core/tests/app/test_app.py | 6 +-- docs/reference/api/app.rst | 40 +++++----------- 4 files changed, 69 insertions(+), 75 deletions(-) create mode 100644 changes/2075.removal.5.rst diff --git a/changes/2075.removal.5.rst b/changes/2075.removal.5.rst new file mode 100644 index 0000000000..07917a49c3 --- /dev/null +++ b/changes/2075.removal.5.rst @@ -0,0 +1 @@ +The ``windows`` constructor argument of ``toga.App`` has been removed. Windows are now automatically added to the current app. diff --git a/core/src/toga/app.py b/core/src/toga/app.py index c4f9597252..bed276fdc4 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -8,7 +8,7 @@ from collections.abc import Iterator, MutableSet from email.message import Message from importlib import metadata as importlib_metadata -from typing import Any, Iterable, Protocol +from typing import Any, Protocol from toga.command import CommandSet from toga.documents import Document @@ -244,7 +244,6 @@ def __init__( description: str | None = None, startup: AppStartupMethod | None = None, on_exit: OnExitHandler | None = None, - windows: Iterable[Window] = (), # DEPRECATED ): """Create a new App instance. @@ -252,27 +251,36 @@ def __init__( :meth:`~toga.App.main_loop()` method, which will start the event loop of your App. - :param formal_name: The formal name of the application. Will be derived from - packaging metadata if not provided. + :param formal_name: The human-readable name of the app. If not provided, + the metadata key ``Formal-Name`` must be present. :param app_id: The unique application identifier. This will usually be a - reversed domain name, e.g. ``org.beeware.myapp``. Will be derived from - packaging metadata if not provided. - :param app_name: The name of the Python module containing the app. Will be - derived from the module defining the instance of the App class if not - provided. - :param id: The DOM identifier for the app (optional) - :param icon: Identifier for the application's icon. + reversed domain name, e.g. ``org.beeware.myapp``. If not provided, the + metadata key ``App-ID`` must be present. + :param app_name: The name of the distribution used to load metadata with + :any:`importlib.metadata`. If not provided, the following will be tried in + order: + + #. If there is a ``__main__`` module with a non-empty ``__package__`` + attribute, that will be used. + #. If the ``app_id`` argument was provided, its last segment will be used, + with each hyphen replaced with a underscore. For example, an ``app_id`` + of ``com.example.my-app`` would yield a distribution name of ``my_app``. + #. As a last resort, the name ``toga``. + :param id: The DOM identifier for the app. If not provided, one will be + automatically generated. + :param icon: The :any:`Icon` for the app. If not provided, Toga will attempt to + load an icon from ``resources/app_name``, where ``app_name`` is defined + above. If no resource matching this name can be found, a warning will be + printed, and the app will fall back to a default icon. :param author: The person or organization to be credited as the author of the - application. Will be derived from application metadata if not provided. - :param version: The version number of the app. Will be derived from packaging - metadata if not provided. - :param home_page: A URL for a home page for the app. Used in auto-generated help - menu items. Will be derived from packaging metadata if not provided. - :param description: A brief (one line) description of the app. Will be derived - from packaging metadata if not provided. - :param startup: The callback method before starting the app, typically to add - the components. - :param windows: **DEPRECATED**; Windows are automatically added to the app. + app. If not provided, the metadata key ``Author`` will be used. + :param version: The version number of the app. If not provided, the metadata + key ``Version`` will be used. + :param home_page: The URL of a web page for the app. Used in auto-generated help + menu items. If not provided, the metadata key ``Home-page`` will be used. + :param description: A brief (one line) description of the app. If not provided, + the metadata key ``Summary`` will be used. + :param startup: A callable to run before starting the app. """ # Initialize empty widgets registry self._widgets = WidgetRegistry() @@ -331,9 +339,8 @@ def __init__( # metadata provides a "Name" key, use that as the app name; otherwise, fall back # to the metadata module name (which might be "toga") if app_name is None: - if "Name" in self.metadata: - self._app_name = self.metadata["Name"] - else: + self._app_name = self.metadata.get("Name") + if self._app_name is None: self._app_name = metadata_module_name # If a name has been provided, use it; otherwise, look to @@ -341,7 +348,7 @@ def __init__( if formal_name: self._formal_name = formal_name else: - self._formal_name = self.metadata["Formal-Name"] + self._formal_name = self.metadata.get("Formal-Name") if self._formal_name is None: raise RuntimeError("Toga application must have a formal name") @@ -354,7 +361,7 @@ def __init__( self._app_id = self.metadata.get("App-ID", None) if self._app_id is None: - raise RuntimeError("Toga application must have an App ID") + raise RuntimeError("Toga application must have an app ID") # If an author has been provided, use it; otherwise, look to # the module metadata. @@ -437,55 +444,57 @@ def paths(self) -> Paths: @property def name(self) -> str: - """The formal name of the app.""" + """Same as :any:`formal_name`.""" return self._formal_name @property def formal_name(self) -> str: - """The formal name of the app.""" + """The human-readable name of the app (read-only).""" return self._formal_name @property def app_name(self) -> str: - """The machine-readable, PEP508-compliant name of the app.""" + """The name of the distribution used to load metadata with + :any:`importlib.metadata` (read-only).""" return self._app_name @property def module_name(self) -> str | None: - """The module name for the app.""" + """The module name for the app (read-only).""" return self._app_name.replace("-", "_") @property def app_id(self) -> str: - """The identifier for the app. - - This is a reversed domain name, often used for targeting resources, etc. + """The unique application identifier (read-only). This will usually be a + reversed domain name, e.g. ``org.beeware.myapp``. """ return self._app_id @property - def author(self) -> str: - """The author of the app. This may be an organization name.""" + def author(self) -> str | None: + """The person or organization to be credited as the author of the app + (read-only).""" return self._author @property - def version(self) -> str: - """The version number of the app.""" + def version(self) -> str | None: + """The version number of the app (read-only).""" return self._version @property - def home_page(self) -> str: - """The URL of a web page for the app.""" + def home_page(self) -> str | None: + """The URL of a web page for the app (read-only). Used in auto-generated help + menu items.""" return self._home_page @property - def description(self) -> str: - """A brief description of the app.""" + def description(self) -> str | None: + """A brief (one line) description of the app (read-only).""" return self._description @property def id(self) -> str: - """The DOM identifier for the app. + """The DOM identifier for the app (read-only). This id can be used to target CSS directives. """ @@ -495,8 +504,8 @@ def id(self) -> str: def icon(self) -> Icon: """The Icon for the app. - When setting the icon, you can provide either an icon instance, or a string that - will be resolved as an Icon resource name. + When setting the icon, you can provide either an :any:`Icon` instance, or a + path that will be passed to the ``Icon`` constructor. """ return self._icon diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index dda6a0d0f9..2f3063e251 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -285,7 +285,7 @@ def test_create( "kwargs, message", [ (dict(), "Toga application must have a formal name"), - (dict(formal_name="Something"), "Toga application must have an App ID"), + (dict(formal_name="Something"), "Toga application must have an app ID"), ], ) def test_bad_app_creation(kwargs, message): @@ -468,11 +468,9 @@ def test_full_screen(): def test_set_empty_full_screen_window_list(): """Setting the full screen window list to [] is an explicit exit""" + app = toga.App(formal_name="Test App", app_id="org.example.test") window1 = toga.Window() window2 = toga.Window() - app = toga.App( - formal_name="Test App", app_id="org.example.test", windows=[window1, window2] - ) assert not app.is_full_screen diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index f56aeeba42..e1ec3b858f 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -15,7 +15,7 @@ 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 Application. That +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. @@ -53,7 +53,6 @@ apps, you should subclass :class:`toga.App`, and provide an implementation of import toga - class MyApp(toga.App): def startup(self): self.main_window = toga.MainWindow() @@ -64,36 +63,23 @@ apps, you should subclass :class:`toga.App`, and provide an implementation of app = MyApp("Realistic App", "org.beeware.realistic") app.main_loop() -When creating an app, you must provide a formal name (a human readable name for the -app), and an App ID (a machine-readable identifier - usually a reversed domain name). -You can provide these details as explicit arguments; however, you can also provide these -details as PEP621 packaging metadata using the ``Formal-Name`` and ``App-ID`` keys. If -you deploy your app with `Briefcase `__, -this metadata will be populated as part of the deployment process. - -A Toga app also has an app name; this is a `PEP508 -`__ module identifier for the app. The app name -can be provided explicitly; however, if it isn't provided explicitly, Toga uses the -following strategy to determine an app name: - -1. If an app name has been explicitly provided, it will be used as-is. -2. If no app name has been explicitly provided, Toga will look for the name of the - parent of the ``__main__`` module for the app. -3. If there is no ``__main__`` module, but an App ID has been explicitly provided, the - last name part of the App ID will be used. For example, an explicit App ID of - ``com.example.my-app`` would yield an app name of ``my-app``. -4. As a last resort, Toga will use the name ``toga`` as an app name. - -Toga will attempt to load an :class:`~toga.Icon` for the app. If an icon is not -specified when the App instance is created, Toga will attempt to use ``resources/`` as the name of the icon (for whatever app name has been provided or derived). If -no resource matching this name can be found, a warning will be printed, and the app will -fall back to a default icon. +Every app must have a formal name (a human readable name), and an app ID (a +machine-readable identifier - usually a reversed domain name). In the examples above, +these are provided as constructor arguments. However, you can also provide these +details, along with many of the other constructor arguments, as packaging metadata in a +format compatible with :any:`importlib.metadata`. If you deploy your app with `Briefcase +`__, this will be done automatically. + +The distribution name used to look up the metadata is determined by the ``app_name`` +constructor argument, which is described below. + Reference --------- .. autoclass:: toga.App + :exclude-members: app + .. autoclass:: toga.app.WindowSet .. autoprotocol:: toga.app.AppStartupMethod From ca8410da2238c71955be1c7e13c801f0b6184ea8 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 25 Oct 2023 10:41:32 +0100 Subject: [PATCH 34/66] Document App.windows, and related fixes --- core/src/toga/app.py | 50 +++++++++++++++++--------------- core/src/toga/window.py | 6 ++-- core/tests/app/test_app.py | 19 ++++++++---- core/tests/app/test_windowset.py | 35 ---------------------- core/tests/test_window.py | 9 ++++++ docs/reference/api/app.rst | 2 -- 6 files changed, 53 insertions(+), 68 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index bed276fdc4..37ca2f9849 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -8,7 +8,7 @@ from collections.abc import Iterator, MutableSet from email.message import Message from importlib import metadata as importlib_metadata -from typing import Any, Protocol +from typing import Any, Iterable, Protocol from toga.command import CommandSet from toga.documents import Document @@ -74,22 +74,14 @@ class WindowSet(MutableSet): def __init__(self, app: App): """A collection of windows managed by an app. - A window is automatically added to the app when it is shown. Alternatively, the - window can be explicitly added to the app (without being shown) using - ``app.windows.add(toga.Window(...))`` or ``app.windows += toga.Window(...)``. - Adding a window to an App's window set automatically sets the + A window is automatically added to the app when it is created, and removed when + it is closed. Adding a window to an App's window set automatically sets the :attr:`~toga.Window.app` property of the Window. - - :param app: The app maintaining the window set. """ self.app = app self.elements = set() def add(self, window: Window) -> None: - """Add a window to the window set. - - :param window: The :class:`toga.Window` to add - """ if not isinstance(window, Window): raise TypeError("Can only add objects of type toga.Window") # Silently not add if duplicate @@ -98,24 +90,12 @@ def add(self, window: Window) -> None: window.app = self.app def discard(self, window: Window) -> None: - """Remove a window from the Window set. - - :param window: The :class:`toga.Window` to remove. - """ if not isinstance(window, Window): raise TypeError("Can only discard objects of type toga.Window") if window not in self.elements: raise ValueError(f"{window!r} is not part of this app") self.elements.remove(window) - def __iadd__(self, window: Window) -> None: - self.add(window) - return self - - def __isub__(self, other: Window) -> None: - self.discard(other) - return self - def __iter__(self) -> Iterator: return iter(self.elements) @@ -244,6 +224,7 @@ def __init__( description: str | None = None, startup: AppStartupMethod | None = None, on_exit: OnExitHandler | None = None, + windows=None, # DEPRECATED ): """Create a new App instance. @@ -281,7 +262,22 @@ def __init__( :param description: A brief (one line) description of the app. If not provided, the metadata key ``Summary`` will be used. :param startup: A callable to run before starting the app. + :param on_exit: The handler to invoke before the application exits. + :param windows: **DEPRECATED** – Windows are now automatically added to the + current app. Passing this argument will cause an exception. """ + ###################################################################### + # 2023-10: Backwards compatibility + ###################################################################### + if windows is not None: + raise ValueError( + "The `windows` constructor argument of toga.App has been removed. " + "Windows are now automatically added to the current app." + ) + ###################################################################### + # End backwards compatibility + ###################################################################### + # Initialize empty widgets registry self._widgets = WidgetRegistry() @@ -416,7 +412,7 @@ def __init__( self._startup_method = startup self._main_window = None - self.windows = WindowSet(self) + self._windows = WindowSet(self) self._full_screen_windows = None @@ -525,6 +521,12 @@ def widgets(self) -> WidgetRegistry: """ return self._widgets + @property + def windows(self) -> Iterable[Window]: + """The windows managed by the app. Windows are added to the app when they are + created, and removed when they are closed.""" + return self._windows + @property def main_window(self) -> MainWindow: """The main window for the app.""" diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 71fa14d5d1..1afee851b5 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -131,7 +131,9 @@ def __init__( ) self._app = None - App.app.windows += self + if App.app is None: + raise RuntimeError("Cannot create a Window before creating an App") + App.app.windows.add(self) self._toolbar = CommandSet(on_change=self._impl.create_toolbar) self.on_close = on_close @@ -310,7 +312,7 @@ def close(self) -> None: undefined, except for :attr:`closed` which can be used to check if the window was closed. """ - self.app.windows -= self + self.app.windows.discard(self) self._impl.close() self._closed = True diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 2f3063e251..68a064d02d 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -282,15 +282,24 @@ def test_create( @pytest.mark.parametrize( - "kwargs, message", + "kwargs, exc_type, message", [ - (dict(), "Toga application must have a formal name"), - (dict(formal_name="Something"), "Toga application must have an app ID"), + (dict(), RuntimeError, "Toga application must have a formal name"), + ( + dict(formal_name="Something"), + RuntimeError, + "Toga application must have an app ID", + ), + ( + dict(windows=()), + ValueError, + "The `windows` constructor argument of toga.App has been removed", + ), ], ) -def test_bad_app_creation(kwargs, message): +def test_bad_app_creation(kwargs, exc_type, message): """Errors are raised""" - with pytest.raises(RuntimeError, match=message): + with pytest.raises(exc_type, match=message): toga.App(**kwargs) diff --git a/core/tests/app/test_windowset.py b/core/tests/app/test_windowset.py index 02033be9fb..4ef71c0184 100644 --- a/core/tests/app/test_windowset.py +++ b/core/tests/app/test_windowset.py @@ -58,38 +58,3 @@ def test_add_discard(app, window1, window2): match=r"Can only discard objects of type toga.Window", ): app.windows.discard(object()) - - -def test_add_discard_by_operator(app, window1, window2): - """An item can be added to a windowset by inline operators""" - # The windowset has 3 windows - the main window, plus 2 extras - assert len(app.windows) == 3 - - with pytest.raises( - TypeError, - match=r"Can only add objects of type toga.Window", - ): - app.windows += object() - - # Explicitly re-add a window that is already in the windowset - app.windows += window2 - assert len(app.windows) == 3 - assert window2 in app.windows - assert window2.app == app - - # Explicitly discard a window that is in the windowset - app.windows -= window2 - assert window2 not in app.windows - - # Duplicate discard - it's no longer a member - with pytest.raises( - ValueError, - match=r" is not part of this app", - ): - app.windows -= window2 - - with pytest.raises( - TypeError, - match=r"Can only discard objects of type toga.Window", - ): - app.windows -= object() diff --git a/core/tests/test_window.py b/core/tests/test_window.py index 11daea276e..82c9812b44 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -70,6 +70,15 @@ def test_window_created_explicit(app): assert window.on_close._raw == on_close_handler +def test_window_created_without_app(): + "A window cannot be created without an active app" + toga.App.app = None + with pytest.raises( + RuntimeError, match="Cannot create a Window before creating an App" + ): + toga.Window() + + def test_set_app(window, app): """A window's app cannot be reassigned""" assert window.app == app diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index e1ec3b858f..d18e2e0e8d 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -80,8 +80,6 @@ Reference .. autoclass:: toga.App :exclude-members: app -.. autoclass:: toga.app.WindowSet - .. autoprotocol:: toga.app.AppStartupMethod .. autoprotocol:: toga.app.BackgroundTask .. autoprotocol:: toga.app.OnExitHandler From 1a2baece4aa9a37b58e8c67e79743f297dde820a Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 25 Oct 2023 15:00:10 +0100 Subject: [PATCH 35/66] Deprecate redundant "id" and "name" properties --- changes/2075.removal.6.rst | 1 + changes/2075.removal.7.rst | 1 + core/src/toga/app.py | 176 ++++++++++++++--------------- core/tests/app/test_app.py | 139 +++++++++++------------ core/tests/command/test_command.py | 2 - docs/reference/api/app.rst | 4 - 6 files changed, 148 insertions(+), 175 deletions(-) create mode 100644 changes/2075.removal.6.rst create mode 100644 changes/2075.removal.7.rst diff --git a/changes/2075.removal.6.rst b/changes/2075.removal.6.rst new file mode 100644 index 0000000000..718c7c3090 --- /dev/null +++ b/changes/2075.removal.6.rst @@ -0,0 +1 @@ +In ``App``, the properties ``id`` and ``name`` have been deprecated in favor of ``app_id`` and ``formal_name`` respectively. diff --git a/changes/2075.removal.7.rst b/changes/2075.removal.7.rst new file mode 100644 index 0000000000..cd9454d1ee --- /dev/null +++ b/changes/2075.removal.7.rst @@ -0,0 +1 @@ +In ``App``, the property ``app_name`` has been renamed to ``distribution_name``, and the property ``module_name`` has been removed. diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 37ca2f9849..79421859b7 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -1,14 +1,14 @@ from __future__ import annotations +import importlib.metadata import signal import sys import warnings import webbrowser -from builtins import id as identifier from collections.abc import Iterator, MutableSet from email.message import Message -from importlib import metadata as importlib_metadata from typing import Any, Iterable, Protocol +from warnings import warn from toga.command import CommandSet from toga.documents import Document @@ -215,8 +215,8 @@ def __init__( self, formal_name: str | None = None, app_id: str | None = None, - app_name: str | None = None, - id: str | None = None, + distribution_name: str | None = None, + *, icon: Icon | str | None = None, author: str | None = None, version: str | None = None, @@ -224,6 +224,8 @@ def __init__( description: str | None = None, startup: AppStartupMethod | None = None, on_exit: OnExitHandler | None = None, + app_name: str | None = None, # DEPRECATED + id=None, # DEPRECATED windows=None, # DEPRECATED ): """Create a new App instance. @@ -237,22 +239,21 @@ def __init__( :param app_id: The unique application identifier. This will usually be a reversed domain name, e.g. ``org.beeware.myapp``. If not provided, the metadata key ``App-ID`` must be present. - :param app_name: The name of the distribution used to load metadata with + :param distribution_name: The name of the distribution used to load metadata with :any:`importlib.metadata`. If not provided, the following will be tried in order: - #. If there is a ``__main__`` module with a non-empty ``__package__`` - attribute, that will be used. - #. If the ``app_id`` argument was provided, its last segment will be used, - with each hyphen replaced with a underscore. For example, an ``app_id`` - of ``com.example.my-app`` would yield a distribution name of ``my_app``. + #. If the ``__main__`` module is contained in a package, that package's name + will be used. + #. If the ``app_id`` argument was provided, its last segment will be used. + For example, an ``app_id`` of ``com.example.my-app`` would yield a + distribution name of ``my-app``. #. As a last resort, the name ``toga``. - :param id: The DOM identifier for the app. If not provided, one will be - automatically generated. :param icon: The :any:`Icon` for the app. If not provided, Toga will attempt to - load an icon from ``resources/app_name``, where ``app_name`` is defined - above. If no resource matching this name can be found, a warning will be - printed, and the app will fall back to a default icon. + load an icon from ``resources/distribution_name``, where + ``distribution_name`` is defined above. If no resource matching this name + can be found, a warning will be printed, and the app will fall back to a + default icon. :param author: The person or organization to be credited as the author of the app. If not provided, the metadata key ``Author`` will be used. :param version: The version number of the app. If not provided, the metadata @@ -263,12 +264,31 @@ def __init__( the metadata key ``Summary`` will be used. :param startup: A callable to run before starting the app. :param on_exit: The handler to invoke before the application exits. + :param app_name: **DEPRECATED** – Renamed to ``distribution_name``. + :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 current app. Passing this argument will cause an exception. """ ###################################################################### # 2023-10: Backwards compatibility ###################################################################### + if app_name is not None: + if distribution_name is not None: + raise ValueError("Cannot specify both app_name and distribution_name") + else: + warn( + "App.app_name has been renamed to distribution_name", + DeprecationWarning, + ) + distribution_name = app_name + + if id is not None: + warn( + "App.id is deprecated and will be ignored. Use app_id instead", + DeprecationWarning, + ) + if windows is not None: raise ValueError( "The `windows` constructor argument of toga.App has been removed. " @@ -284,112 +304,79 @@ def __init__( # Keep an accessible copy of the app singleton instance App.app = self - # We need a module name to load app metadata. If an app_name has been - # provided, we can set the app name now, and derive the module name - # from there. - self._app_name = app_name - if app_name: - metadata_module_name = self.module_name - else: + # We need a distribution name to load app metadata. + if distribution_name is None: # If the code is contained in appname.py, and you start the app using - # `python -m appname`, the main module package will report as ''. Set the - # metadata module name as None. + # `python -m appname`, then __main__.__package__ will be an empty string. # # If the code is contained in appname.py, and you start the app using - # `python appname.py`, the metadata module name will report as None. + # `python appname.py`, then __main__.__package__ will be None. # - # If the code is contained in a folder, and you start the app using `python - # -m appname`, the metadata module name will report as the name of the - # folder. + # If the code is contained in appname/__main__.py, and you start the app + # using `python -m appname`, then __main__.__package__ will be "appname". try: main_module_pkg = sys.modules["__main__"].__package__ - if main_module_pkg == "": - metadata_module_name = None - else: - metadata_module_name = main_module_pkg + if main_module_pkg: + distribution_name = main_module_pkg except KeyError: - # We use the existence of a __main__ module as a proxy for - # being in test conditions. This isn't *great*, but the __main__ - # module isn't meaningful during tests, and removing it allows - # us to avoid having explicit "if under test conditions" checks. - # If there's no __main__ module, we're in a test, and we can't - # imply an app name from that module name. - metadata_module_name = None - - # Try deconstructing the metadata module name from the app ID - if metadata_module_name is None and app_id: - metadata_module_name = app_id.split(".")[-1].replace("-", "_") - - # If we still don't have a metadata module name, fall back to ``toga`` as a - # last resort. - if metadata_module_name is None: - metadata_module_name = "toga" - - # Try to load the app metadata with our best guess of the module name. + # If there's no __main__ module, we're probably in a test. + pass + + # Try deconstructing the distribution name from the app ID + if (distribution_name is None) and app_id: + distribution_name = app_id.split(".")[-1] + + # If we still don't have a distribution name, fall back to ``toga`` as a + # last resort. + if distribution_name is None: + distribution_name = "toga" + + # Try to load the app metadata with our best guess of the distribution name. + self._distribution_name = distribution_name try: - self.metadata = importlib_metadata.metadata(metadata_module_name) - except importlib_metadata.PackageNotFoundError: + self.metadata = importlib.metadata.metadata(distribution_name) + except importlib.metadata.PackageNotFoundError: self.metadata = Message() - # If the app name wasn't explicitly provided, look to the app metadata. If the - # metadata provides a "Name" key, use that as the app name; otherwise, fall back - # to the metadata module name (which might be "toga") - if app_name is None: - self._app_name = self.metadata.get("Name") - if self._app_name is None: - self._app_name = metadata_module_name - - # If a name has been provided, use it; otherwise, look to - # the module metadata. However, a name *must* be provided. + # If a formal name has been provided, use it; otherwise, look to + # the metadata. However, a formal name *must* be provided. if formal_name: self._formal_name = formal_name else: self._formal_name = self.metadata.get("Formal-Name") - if self._formal_name is None: raise RuntimeError("Toga application must have a formal name") # If an app_id has been provided, use it; otherwise, look to - # the module metadata. However, an app_id *must* be provided + # the metadata. However, an app_id *must* be provided if app_id: self._app_id = app_id else: self._app_id = self.metadata.get("App-ID", None) - if self._app_id is None: raise RuntimeError("Toga application must have an app ID") - # If an author has been provided, use it; otherwise, look to - # the module metadata. + # Other metadata may be passed to the constructor, or loaded with importlib. if author: self._author = author else: self._author = self.metadata.get("Author", None) - # If a version has been provided, use it; otherwise, look to - # the module metadata. if version: self._version = version else: self._version = self.metadata.get("Version", None) - # If a home_page has been provided, use it; otherwise, look to - # the module metadata. if home_page: self._home_page = home_page else: self._home_page = self.metadata.get("Home-page", None) - # If a description has been provided, use it; otherwise, look to - # the module metadata. if description: self._description = description else: self._description = self.metadata.get("Summary", None) - # Set the application DOM ID; create an ID if one hasn't been provided. - self._id = str(id if id else identifier(self)) - # Get a platform factory. self.factory = get_platform_factory() @@ -397,11 +384,11 @@ def __init__( self._paths = Paths() # If an icon (or icon name) has been explicitly provided, use it; - # otherwise, the icon will be based on the app name. + # otherwise, the icon will be based on the distribution name. if icon: self.icon = icon else: - self.icon = f"resources/{self.app_name}" + self.icon = f"resources/{distribution_name}" self.on_exit = on_exit @@ -440,7 +427,8 @@ def paths(self) -> Paths: @property def name(self) -> str: - """Same as :any:`formal_name`.""" + """**DEPRECATED** – Use :any:`formal_name`.""" + warn("App.name is deprecated. Use formal_name instead", DeprecationWarning) return self._formal_name @property @@ -449,15 +437,16 @@ def formal_name(self) -> str: return self._formal_name @property - def app_name(self) -> str: + def distribution_name(self) -> str: """The name of the distribution used to load metadata with :any:`importlib.metadata` (read-only).""" - return self._app_name + return self._distribution_name @property - def module_name(self) -> str | None: - """The module name for the app (read-only).""" - return self._app_name.replace("-", "_") + def app_name(self) -> str: + """**DEPRECATED** – Renamed to ``distribution_name``.""" + warn("App.app_name has been renamed to distribution_name", DeprecationWarning) + return self._distribution_name @property def app_id(self) -> str: @@ -490,11 +479,9 @@ def description(self) -> str | None: @property def id(self) -> str: - """The DOM identifier for the app (read-only). - - This id can be used to target CSS directives. - """ - return self._id + """**DEPRECATED** – Use :any:`app_id`.""" + warn("App.id is deprecated. Use app_id instead", DeprecationWarning) + return self._app_id @property def icon(self) -> Icon: @@ -683,8 +670,8 @@ def __init__( self, formal_name: str | None = None, app_id: str | None = None, - app_name: str | None = None, - id: str | None = None, + distribution_name: str | None = None, + *, icon: str | None = None, author: str | None = None, version: str | None = None, @@ -693,6 +680,8 @@ def __init__( startup: AppStartupMethod | None = None, document_types: dict[str, type[Document]] = None, on_exit: OnExitHandler | None = None, + app_name: str | None = None, # DEPRECATED + id=None, # DEPRECATED ): """Create a document-based application. @@ -716,8 +705,7 @@ def __init__( super().__init__( formal_name=formal_name, app_id=app_id, - app_name=app_name, - id=id, + distribution_name=distribution_name, icon=icon, author=author, version=version, @@ -725,6 +713,8 @@ def __init__( description=description, startup=startup, on_exit=on_exit, + app_name=app_name, + id=id, ) def _create_impl(self): diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 68a064d02d..8196ae2746 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -1,7 +1,7 @@ import asyncio +import importlib.metadata import sys import webbrowser -from importlib import metadata as importlib_metadata from pathlib import Path from unittest.mock import Mock @@ -17,7 +17,7 @@ EXPLICIT_FULL_APP_KWARGS = dict( formal_name="Explicit App", app_id="org.beeware.explicit-app", - app_name="override-app", + distribution_name="override-app", ) EXPLICIT_MIN_APP_KWARGS = dict( formal_name="Explicit App", @@ -32,8 +32,8 @@ @pytest.mark.parametrize( ( - "kwargs, metadata, main_module, expected_formal_name, expected_app_id, expected_app_name, " - "expected_initial_module_name, expected_module_name" + "kwargs, metadata, main_module, expected_formal_name, expected_app_id, " + "expected_distribution_name" ), [ ########################################################################### @@ -48,19 +48,16 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), - # Explicit app properties, but implied app name from app_id, no metadata + # Explicit app properties, but implied distribution name from app_id, no + # metadata ( EXPLICIT_MIN_APP_KWARGS, None, Mock(__package__=None), "Explicit App", "org.beeware.explicit-app", - "explicit_app", - "explicit_app", - "explicit_app", + "explicit-app", ), # No app properties, with metadata ( @@ -69,14 +66,9 @@ Mock(__package__=None), "Test App", "org.beeware.test-app", - "test-app", "toga", - "test_app", ), - # Explicit app properties, with metadata. - # Initial data will be derived by reading the metadata from the original app - # name, but this value will be overridden by the metadata. This is an unlikely, - # but theoretically possible scenario. + # Explicit app properties, with metadata. Explicit values take precedence. ( EXPLICIT_FULL_APP_KWARGS, APP_METADATA, @@ -84,8 +76,6 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), ########################################################################### # Invoking as python -m my_app, where code is in my_app.py @@ -99,19 +89,15 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), - # Explicit app properties, but implied app name from app_id, no metadata + # Explicit app properties, but implied distribution name from app_id, no metadata ( EXPLICIT_MIN_APP_KWARGS, None, Mock(__package__=""), "Explicit App", "org.beeware.explicit-app", - "explicit_app", - "explicit_app", - "explicit_app", + "explicit-app", ), # No app properties, with metadata ( @@ -120,14 +106,9 @@ Mock(__package__=""), "Test App", "org.beeware.test-app", - "test-app", "toga", - "test_app", ), - # Explicit app properties, with metadata. - # Initial data will be derived by reading the metadata from the original app - # name, but this value will be overridden by the metadata. This is an unlikely, - # but theoretically possible scenario. + # Explicit app properties, with metadata. Explicit values take precedence. ( EXPLICIT_FULL_APP_KWARGS, APP_METADATA, @@ -135,8 +116,6 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), ########################################################################### # Invoking as python -m my_app, where my_app is a folder with a __main__ @@ -150,10 +129,9 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), - # Explicit app properties, but implied app name from app_id, no metadata + # Explicit app properties, but implied distribution name from __package__, no + # metadata ( EXPLICIT_MIN_APP_KWARGS, None, @@ -161,8 +139,6 @@ "Explicit App", "org.beeware.explicit-app", "my_app", - "my_app", - "my_app", ), # No app properties, with metadata ( @@ -171,13 +147,9 @@ Mock(__package__="my_app"), "Test App", "org.beeware.test-app", - "test-app", "my_app", - "test_app", ), - # Explicit app properties, with metadata. - # Initial data will be derived by reading the metadata from the original app - # name. This can happen if the app metadata doesn't match the package name. + # Explicit app properties, with metadata. Explicit values take precedence. ( EXPLICIT_FULL_APP_KWARGS, APP_METADATA, @@ -185,8 +157,6 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), ########################################################################### # Invoking in a test harness, where there's no __main__ @@ -199,19 +169,15 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), - # Explicit app properties, but implied app name from app_id, no metadata + # Explicit app properties, but implied distribution name from app_id, no metadata ( EXPLICIT_MIN_APP_KWARGS, None, None, "Explicit App", "org.beeware.explicit-app", - "explicit_app", - "explicit_app", - "explicit_app", + "explicit-app", ), # No app properties, with metadata ( @@ -220,9 +186,7 @@ None, "Test App", "org.beeware.test-app", - "test-app", "toga", - "test_app", ), # Explicit app properties, with metadata. Explicit values take precedence. ( @@ -232,8 +196,6 @@ "Explicit App", "org.beeware.explicit-app", "override-app", - "override_app", - "override_app", ), ], ) @@ -244,9 +206,7 @@ def test_create( main_module, expected_formal_name, expected_app_id, - expected_app_name, - expected_initial_module_name, - expected_module_name, + expected_distribution_name, ): """A simple app can be created""" # Monkeypatch the metadata retrieval function @@ -254,11 +214,11 @@ def test_create( metadata_mock = Mock(return_value=metadata) else: metadata_mock = Mock( - side_effect=importlib_metadata.PackageNotFoundError( - expected_initial_module_name + side_effect=importlib.metadata.PackageNotFoundError( + expected_distribution_name ) ) - monkeypatch.setattr(importlib_metadata, "metadata", metadata_mock) + monkeypatch.setattr(importlib.metadata, "metadata", metadata_mock) # Monkeypatch the main module if main_module is None: @@ -272,13 +232,11 @@ def test_create( app = toga.App(**kwargs) assert app.formal_name == expected_formal_name - assert app.name == expected_formal_name assert app.app_id == expected_app_id - assert app.app_name == expected_app_name - assert app.module_name == expected_module_name + assert app.distribution_name == expected_distribution_name assert app.on_exit._raw is None - metadata_mock.assert_called_once_with(expected_initial_module_name) + metadata_mock.assert_called_once_with(expected_distribution_name) @pytest.mark.parametrize( @@ -306,7 +264,7 @@ def test_bad_app_creation(kwargs, exc_type, message): def test_app_metadata(monkeypatch): """An app can load metadata from the .dist-info file""" monkeypatch.setattr( - importlib_metadata, + importlib.metadata, "metadata", Mock( return_value={ @@ -328,7 +286,6 @@ def test_app_metadata(monkeypatch): app_id="org.example.test-app", ) - assert app.id == str(id(app)) assert app.author == "Jane Developer" assert app.version == "1.2.3" assert app.home_page == "https://example.com/test-app" @@ -338,7 +295,7 @@ def test_app_metadata(monkeypatch): def test_explicit_app_metadata(monkeypatch): """App metadata can be provided explicitly, overriding module-level metadata""" monkeypatch.setattr( - importlib_metadata, + importlib.metadata, "metadata", Mock( return_value={ @@ -356,7 +313,6 @@ def test_explicit_app_metadata(monkeypatch): on_exit_handler = Mock() app = toga.App( - id="testapp-id", formal_name="Test App", app_id="org.example.test-app", author="Jane Developer", @@ -366,7 +322,6 @@ def test_explicit_app_metadata(monkeypatch): on_exit=on_exit_handler, ) - assert app.id == "testapp-id" assert app.author == "Jane Developer" assert app.version == "1.2.3" assert app.home_page == "https://example.com/test-app" @@ -388,8 +343,6 @@ def test_icon_construction(construct): app_id="org.example.test", icon=icon, ) - - # Default icon matches app name assert isinstance(app.icon, toga.Icon) assert app.icon.path == Path("path/to/icon") @@ -402,14 +355,12 @@ def test_icon(app, construct): else: icon = "path/to/icon" - # Default icon matches app name + # Default icon matches distribution name assert isinstance(app.icon, toga.Icon) - assert app.icon.path == Path("resources/test_app") + assert app.icon.path == Path("resources/test-app") # Change icon app.icon = icon - - # Default icon matches app name assert isinstance(app.icon, toga.Icon) assert app.icon.path == Path("path/to/icon") @@ -627,7 +578,7 @@ def test_exit_rejected_handler(app): def test_background_task(app): - """A mbackground task can be queued""" + """A background task can be queued""" canary = Mock() async def background(app, **kwargs): @@ -643,3 +594,39 @@ async def waiter(): # Once the loop has executed, the background task should have executed as well. canary.assert_called_once() + + +def test_deprecated_app_name(): + """The deprecated `app_name` constructor argument and property is redirected to + `distribution_name` + """ + app_name_warning = r"App.app_name has been renamed to distribution_name" + with pytest.warns(DeprecationWarning, match=app_name_warning): + app = toga.App("Test App", "org.example.test", app_name="test_app_name") + + assert app.distribution_name == "test_app_name" + with pytest.warns(DeprecationWarning, match=app_name_warning): + assert app.app_name == "test_app_name" + + +def test_deprecated_id(): + """The deprecated `id` constructor argument is ignored, and the property of the same + name is redirected to `app_id` + """ + id_warning = r"App.id is deprecated.* Use app_id instead" + with pytest.warns(DeprecationWarning, match=id_warning): + app = toga.App("Test App", "org.example.test", id="test_app_id") + + assert app.app_id == "org.example.test" + with pytest.warns(DeprecationWarning, match=id_warning): + assert app.id == "org.example.test" + + +def test_deprecated_name(): + """The deprecated `name` property is redirected to `formal_name`""" + name_warning = r"App.name is deprecated. Use formal_name instead" + app = toga.App("Test App", "org.example.test") + + assert app.formal_name == "Test App" + with pytest.warns(DeprecationWarning, match=name_warning): + assert app.name == "Test App" diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index b71ed2dfff..5eb032a977 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -85,8 +85,6 @@ def test_icon_construction(app, construct): icon = "path/to/icon" cmd = toga.Command(None, "Test command", icon=icon) - - # Default icon matches app name assert isinstance(cmd.icon, toga.Icon) assert cmd.icon.path == Path("path/to/icon") diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index d18e2e0e8d..d0c097a974 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -70,10 +70,6 @@ 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. -The distribution name used to look up the metadata is determined by the ``app_name`` -constructor argument, which is described below. - - Reference --------- From 91271045e630f637d254bec1d7c0a1ffff64db92 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 25 Oct 2023 15:24:30 +0100 Subject: [PATCH 36/66] Add `App.loop` property --- core/src/toga/app.py | 13 ++++++++++--- core/tests/app/test_app.py | 18 +++++++++++++++++- dummy/src/toga_dummy/app.py | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 79421859b7..d5595f5505 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import importlib.metadata import signal import sys @@ -621,9 +622,10 @@ def beep(self) -> None: self._impl.beep() def main_loop(self) -> None: - """Invoke the application to handle user input. + """Start the application. - This method typically only returns once the application is exiting. + On desktop platforms, this method will block until the application has exited. + On mobile and web platforms, it returns immediately. """ # Modify signal handlers to make sure Ctrl-C is caught and handled. signal.signal(signal.SIGINT, signal.SIG_DFL) @@ -651,6 +653,11 @@ def cleanup(app, should_exit): self._on_exit = wrapped_handler(self, handler, cleanup=cleanup) + @property + def loop(self) -> asyncio.AbstractEventLoop: + """The event loop of the app's main thread (read-only).""" + return self._impl.loop + def add_background_task(self, handler: BackgroundTask) -> None: """Schedule a task to run in the background. @@ -662,7 +669,7 @@ def add_background_task(self, handler: BackgroundTask) -> None: :param handler: A coroutine, generator or callable. """ - self._impl.loop.call_soon_threadsafe(wrapped_handler(self, handler), None) + self.loop.call_soon_threadsafe(wrapped_handler(self, handler), None) class DocumentApp(App): diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 8196ae2746..fac72d68cb 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -577,6 +577,12 @@ def test_exit_rejected_handler(app): on_exit_handler.assert_called_once_with(app) +def test_loop(app, event_loop): + """The main thread's event loop can be accessed""" + assert isinstance(app.loop, asyncio.AbstractEventLoop) + assert app.loop is event_loop + + def test_background_task(app): """A background task can be queued""" canary = Mock() @@ -590,7 +596,7 @@ async def background(app, **kwargs): async def waiter(): await asyncio.sleep(0.1) - app._impl.loop.run_until_complete(waiter()) + app.loop.run_until_complete(waiter()) # Once the loop has executed, the background task should have executed as well. canary.assert_called_once() @@ -600,6 +606,16 @@ def test_deprecated_app_name(): """The deprecated `app_name` constructor argument and property is redirected to `distribution_name` """ + with pytest.raises( + ValueError, match="Cannot specify both app_name and distribution_name" + ): + toga.App( + "Test App", + "org.example.test", + app_name="test_app_name", + distribution_name="test_distribution_name", + ) + app_name_warning = r"App.app_name has been renamed to distribution_name" with pytest.warns(DeprecationWarning, match=app_name_warning): app = toga.App("Test App", "org.example.test", app_name="test_app_name") diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index d87aaf32e7..1aa3a7b59f 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -18,7 +18,7 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self - self.loop = asyncio.new_event_loop() + self.loop = asyncio.get_event_loop() self.create() def create(self): From 5fdf934a85b4dadf9968d93f73537bf88c1bf877 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 25 Oct 2023 17:39:50 +0100 Subject: [PATCH 37/66] Replace uses of App.name --- cocoa/src/toga_cocoa/app.py | 2 +- gtk/src/toga_gtk/app.py | 12 ++++++------ winforms/src/toga_winforms/app.py | 15 ++++++--------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 9128db7b89..39efa2a087 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -405,7 +405,7 @@ def show_about_dialog(self): options = NSMutableDictionary.alloc().init() options[NSAboutPanelOptionApplicationIcon] = self.interface.icon._impl.native - options[NSAboutPanelOptionApplicationName] = self.interface.name + options[NSAboutPanelOptionApplicationName] = self.interface.formal_name if self.interface.version is None: options[NSAboutPanelOptionApplicationVersion] = "0.0" diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 840bb64ff7..57e99bbe74 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -79,14 +79,14 @@ def gtk_startup(self, data=None): self.interface.commands.add( Command( self._menu_about, - "About " + self.interface.name, + "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.name, + "Quit " + self.interface.formal_name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, section=sys.maxsize, @@ -184,7 +184,7 @@ def _submenu(self, group, menubar): text = group.text if text == "*": - text = self.interface.name + text = self.interface.formal_name parent_menu.append_submenu(text, submenu) @@ -209,7 +209,7 @@ def show_about_dialog(self): 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.name) + self.native_about_dialog.set_program_name(self.interface.formal_name) if self.interface.version is not None: self.native_about_dialog.set_version(self.interface.version) if self.interface.author is not None: @@ -281,7 +281,7 @@ def gtk_startup(self, data=None): # Is there a way to open a file dialog without having a window? m = toga.Window() path = m.open_file_dialog( - self.interface.name, + self.interface.formal_name, file_types=self.interface.document_types.keys(), ) @@ -292,7 +292,7 @@ def open_file(self, widget, **kwargs): # Is there a way to open a file dialog without having a window? m = toga.Window() path = m.open_file_dialog( - self.interface.name, + self.interface.formal_name, file_types=self.interface.document_types.keys(), ) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 5da82a797d..0075565dc0 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -111,14 +111,14 @@ def create(self): self.interface.commands.add( toga.Command( lambda _: self.interface.about(), - f"About {self.interface.name}", + f"About {self.interface.formal_name}", group=toga.Group.HELP, ), toga.Command(None, "Preferences", group=toga.Group.FILE), # Quit should always be the last item, in a section on its own toga.Command( lambda _: self.interface.on_exit(None), - "Exit " + self.interface.name, + "Exit", # A Windows exit command doesn't usually contain the app name. shortcut=Key.MOD_1 + "q", group=toga.Group.FILE, section=sys.maxsize, @@ -275,16 +275,13 @@ def main_loop(self): def show_about_dialog(self): message_parts = [] - if self.interface.name is not None: + if self.interface.formal_name is not None: if self.interface.version is not None: message_parts.append( - "{name} v{version}".format( - name=self.interface.name, - version=self.interface.version, - ) + f"{self.interface.formal_name} v{self.interface.version}" ) else: - message_parts.append(f"{self.interface.name}") + message_parts.append(self.interface.formal_name) elif self.interface.version is not None: message_parts.append(f"v{self.interface.version}") @@ -293,7 +290,7 @@ def show_about_dialog(self): if self.interface.description is not None: message_parts.append(f"\n{self.interface.description}") self.interface.main_window.info_dialog( - f"About {self.interface.name}", "\n".join(message_parts) + f"About {self.interface.formal_name}", "\n".join(message_parts) ) def beep(self): From f20899a5fb6fdc470615b5689d7db88f55e85b5e Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 25 Oct 2023 18:21:57 +0100 Subject: [PATCH 38/66] Fix WinForms menu initialization --- winforms/src/toga_winforms/app.py | 50 +++++++++++++--------------- winforms/src/toga_winforms/window.py | 2 +- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 85e33c24e3..5e46c232c3 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -108,33 +108,10 @@ def create(self): "You may experience difficulties accessing some web server content." ) - self.interface.commands.add( - toga.Command( - lambda _: self.interface.about(), - f"About {self.interface.formal_name}", - group=toga.Group.HELP, - ), - toga.Command(None, "Preferences", group=toga.Group.FILE), - # Quit should always be the last item, in a section on its own - toga.Command( - lambda _: self.interface.on_exit(None), - "Exit", # A Windows exit command doesn't usually contain the app name. - 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, - ), - ) - self._create_app_commands() - # Call user code to populate the main window self.interface._startup() self.create_menus() + self._create_app_commands() self.interface.main_window._impl.set_app(self) def create_menus(self): @@ -195,8 +172,28 @@ def _submenu(self, group, menubar): return submenu def _create_app_commands(self): - # No extra menus - pass + self.interface.commands.add( + toga.Command( + lambda _: self.interface.about(), + f"About {self.interface.formal_name}", + group=toga.Group.HELP, + ), + toga.Command(None, "Preferences", group=toga.Group.FILE), + # Quit should always be the last item, in a section on its own + toga.Command( + lambda _: self.interface.on_exit(None), + "Exit", # A Windows exit command doesn't usually contain the app name. + 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, + ), + ) def open_document(self, fileURL): """Add a new document to this app.""" @@ -332,6 +329,7 @@ def hide_cursor(self): class DocumentApp(App): def _create_app_commands(self): + super()._create_app_commands() self.interface.commands.add( toga.Command( lambda w: self.open_file, diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index e887613531..660178133b 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -1,7 +1,7 @@ import System.Windows.Forms as WinForms from System.Drawing import Point, Size -from toga import GROUP_BREAK, SECTION_BREAK +from toga.command import GROUP_BREAK, SECTION_BREAK from .container import Container from .libs.wrapper import WeakrefCallable From 51141bbab51a45b6b5e15ce1b214d89187859d9e Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 26 Oct 2023 10:35:15 +0100 Subject: [PATCH 39/66] More App documentation cleanups --- core/src/toga/app.py | 60 ++++++++++++++++++----------------------- core/src/toga/window.py | 11 +++++++- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index d5595f5505..4d5ce62521 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -6,9 +6,9 @@ import sys import warnings import webbrowser -from collections.abc import Iterator, MutableSet +from collections.abc import Collection, Iterator, Mapping, MutableSet from email.message import Message -from typing import Any, Iterable, Protocol +from typing import Any, Protocol from warnings import warn from toga.command import CommandSet @@ -30,11 +30,9 @@ def __call__(self, app: App, **kwargs: Any) -> Widget: Called during app startup to set the initial main window content. - .. note:: - ``**kwargs`` ensures compatibility with additional arguments - introduced in future versions. - :param app: The app instance that is starting. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. :returns: The widget to use as the main window content. """ ... @@ -47,11 +45,9 @@ def __call__(self, app: App, **kwargs: Any) -> bool: The return value of this callback controls whether the app is allowed to exit. This can be used to prevent the app exiting with unsaved changes, etc. - .. note:: - ``**kwargs`` ensures compatibility with additional arguments - introduced in future versions. - :param app: The app instance that is exiting. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. :returns: ``True`` if the app is allowed to exit; ``False`` if the app is not allowed to exit. """ @@ -62,11 +58,9 @@ class BackgroundTask(Protocol): def __call__(self, app: App, **kwargs: Any) -> None: """Code that should be executed as a background task. - .. note:: - ``**kwargs`` ensures compatibility with additional arguments - introduced in future versions. - :param app: The app that is handling the background task. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. """ ... @@ -264,7 +258,7 @@ def __init__( :param description: A brief (one line) description of the app. If not provided, the metadata key ``Summary`` will be used. :param startup: A callable to run before starting the app. - :param on_exit: The handler to invoke before the application exits. + :param on_exit: The initial :any:`on_exit` handler. :param app_name: **DEPRECATED** – Renamed to ``distribution_name``. :param id: **DEPRECATED** - This argument will be ignored. If you need a machine-friendly identifier, use ``app_id``. @@ -414,15 +408,11 @@ def _create_impl(self): @property def paths(self) -> Paths: - """Paths for platform appropriate locations on the user's file system. - - Some platforms do not allow arbitrary file access to any location on disk; even - when arbitrary file system access is allowed, there are "preferred" locations - for some types of content. + """Paths for platform-appropriate locations on the user's file system. - The :class:`~toga.paths.Paths` object has a set of sub-properties that return - :class:`pathlib.Path` instances of platform-appropriate paths on the file - system. + Some platforms do not allow access to any file system location other than these + paths. Even when arbitrary file access is allowed, there are preferred locations + for each type of content. """ return self._paths @@ -501,7 +491,7 @@ def icon(self, icon_or_name: Icon | str) -> None: self._icon = Icon(icon_or_name) @property - def widgets(self) -> WidgetRegistry: + def widgets(self) -> Mapping[str, Widget]: """The widgets managed by the app, over all windows. Can be used to look up widgets by ID over the entire app (e.g., @@ -510,9 +500,9 @@ def widgets(self) -> WidgetRegistry: return self._widgets @property - def windows(self) -> Iterable[Window]: - """The windows managed by the app. Windows are added to the app when they are - created, and removed when they are closed.""" + def windows(self) -> Collection[Window]: + """The windows managed by the app. Windows are automatically added to the app + when they are created, and removed when they are closed.""" return self._windows @property @@ -527,7 +517,7 @@ def main_window(self, window: MainWindow) -> None: @property def current_window(self) -> Window | None: - """Return the currently active content window.""" + """Return the currently active window.""" window = self._impl.get_current_window() if window is None: return None @@ -578,8 +568,8 @@ def startup(self) -> None: """Create and show the main window for the application. Subclasses can override this method to define customized startup behavior; - however, as a result of invoking this method, the app *must* have a - ``main_window``. + however, any override *must* ensure the :any:`main_window` has been assigned + before it returns. """ self.main_window = MainWindow(title=self.formal_name) @@ -610,9 +600,9 @@ def about(self) -> None: self._impl.show_about_dialog() def visit_homepage(self) -> None: - """Open the application's homepage in the default browser. + """Open the application's :any:`home_page` in the default browser. - If the application metadata doesn't define a homepage, this is a no-op. + If the :any:`home_page` is ``None``, this is a no-op. """ if self.home_page is not None: webbrowser.open(self.home_page) @@ -642,7 +632,7 @@ def exit(self) -> None: @property def on_exit(self) -> OnExitHandler: - """The handler to invoke before the application exits.""" + """The handler to invoke if the user attempts to exit the app.""" return self._on_exit @on_exit.setter @@ -655,7 +645,9 @@ def cleanup(app, should_exit): @property def loop(self) -> asyncio.AbstractEventLoop: - """The event loop of the app's main thread (read-only).""" + """The `event loop + `__ of the app's main + thread (read-only).""" return self._impl.loop def add_background_task(self, handler: BackgroundTask) -> None: diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 1afee851b5..d88f3f381d 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -2,6 +2,7 @@ import warnings from builtins import id as identifier +from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, overload @@ -110,7 +111,7 @@ def __init__( # Needs to be a late import to avoid circular dependencies. from toga import App - self.widgets = WidgetRegistry() + self._widgets = WidgetRegistry() self._id = str(id if id else identifier(self)) self._impl = None @@ -227,6 +228,14 @@ def content(self, widget: Widget) -> None: # Update the geometry of the widget widget.refresh() + @property + def widgets(self) -> Mapping[str, Widget]: + """The widgets contained in the window. + + Can be used to look up widgets by ID (e.g., ``window.widgets["my_id"]``). + """ + return self._widgets + @property def size(self) -> tuple[int, int]: """Size of the window, as a tuple of ``(width, height)``, in From b7977d2606c51313e979c3f0ae5aa877f78efd83 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 26 Oct 2023 14:48:17 +0100 Subject: [PATCH 40/66] Command documentation cleanups --- changes/2075.removal.8.rst | 1 + core/src/toga/app.py | 9 ++- core/src/toga/command.py | 95 ++++++++++++----------- core/src/toga/window.py | 6 +- core/tests/command/conftest.py | 6 +- core/tests/command/test_group.py | 10 +-- docs/reference/api/resources/command.rst | 97 +++++------------------- 7 files changed, 86 insertions(+), 138 deletions(-) create mode 100644 changes/2075.removal.8.rst diff --git a/changes/2075.removal.8.rst b/changes/2075.removal.8.rst new file mode 100644 index 0000000000..3e103e8f2c --- /dev/null +++ b/changes/2075.removal.8.rst @@ -0,0 +1 @@ +The optional arguments of ``Command`` and ``Group`` are now keyword-only. diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 4d5ce62521..a9f2e4489d 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -11,7 +11,7 @@ from typing import Any, Protocol from warnings import warn -from toga.command import CommandSet +from toga.command import Command, CommandSet from toga.documents import Document from toga.handlers import wrapped_handler from toga.icons import Icon @@ -389,7 +389,7 @@ def __init__( # 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._commands = CommandSet() self._startup_method = startup @@ -505,6 +505,11 @@ def windows(self) -> Collection[Window]: when they are created, and removed when they are closed.""" return self._windows + @property + def commands(self) -> MutableSet[Command]: + """The commands available in the app.""" + return self._commands + @property def main_window(self) -> MainWindow: """The main window for the app.""" diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 15f4006122..0ee13441d2 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -14,33 +14,27 @@ class Group: def __init__( self, text: str, - order: int | None = None, - section: int | None = None, + *, parent: Group | None = None, + section: int = 0, + order: int = 0, ): """ - An collection of similar commands. - - Commands and sub-groups are sorted within sections inside a group. - - Groups can also be hierarchical; a group with no parent is a root group. - - :param text: The name of the group - :param order: An integer that can be used to provide sorting order for commands. - Commands will be sorted according to order inside their section; if a - Command doesn't have an order, it will be sorted alphabetically by text - within its section. - :param section: An integer describing the section within the parent group where - the command should appear. If no section is specified, the command will be - allocated to section 0 within the group. A section cannot be specified - unless a parent is also specified. - :param parent: The parent of this group; use ``None`` to describe a root group. + An collection of commands to display together. + + :param text: A label for the group. + :param parent: The parent of this group; use ``None`` to make a root group. + :param section: The section where the group should appear within its parent. A + section cannot be specified unless a parent is also specified. + :param order: The position where the group should appear within its section. + If multiple items have the same group, section and order, they will be + sorted alphabetically by their text. """ self.text = text - self.order = order if order else 0 - if parent is None and section is not None: + self.order = order + if parent is None and section != 0: raise ValueError("Section cannot be set without parent group") - self.section = section if section else 0 + self.section = section # Prime the underlying value of _parent so that the setter has a current value # to work with @@ -49,7 +43,7 @@ def __init__( @property def parent(self) -> Group | None: - """The parent of this group; returns ``None`` if the group is a root group""" + """The parent of this group; returns ``None`` if the group is a root group.""" return self._parent @parent.setter @@ -75,7 +69,7 @@ def root(self) -> Group: return self.parent.root def is_parent_of(self, child: Group | None) -> bool: - """Is this group a parent of the provided group? + """Is this group a parent of the provided group, directly or indirectly? :param child: The potential child to check :returns: True if this group is a parent of the provided child. @@ -89,7 +83,7 @@ def is_parent_of(self, child: Group | None) -> bool: return self.is_parent_of(child.parent) def is_child_of(self, parent: Group | None) -> bool: - """Is this group a child of the provided group? + """Is this group a child of the provided group, directly or indirectly? :param parent: The potential parent to check :returns: True if this group is a child of the provided parent. @@ -132,6 +126,16 @@ def key(self) -> tuple[(int, int, str)]: return tuple([self_tuple]) return tuple([*self.parent.key, self_tuple]) + # Standard groups - docstrings can only be provided within the `class` statement, + # but the objects can't be instantiated here. + APP = None #: Application-level commands + FILE = None #: File commands + EDIT = None #: Editing commands + VIEW = None #: Content appearance commands + COMMANDS = None #: Default group for user-provided commands + WINDOW = None #: Window management commands + HELP = None #: Help commands + Group.APP = Group("*", order=0) Group.FILE = Group("File", order=1) @@ -146,11 +150,9 @@ class ActionHandler(Protocol): def __call__(self, command: Command, **kwargs) -> bool: """A handler that will be invoked when a Command is invoked. - .. note:: - ``**kwargs`` ensures compatibility with additional arguments - introduced in future versions. - :param command: The command that triggered the action. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. """ ... @@ -160,31 +162,32 @@ def __init__( self, action: ActionHandler | None, text: str, + *, shortcut: str | None = None, tooltip: str | None = None, icon: str | Icon | None = None, - group: Group | None = None, - section: int | None = None, - order: int | None = None, + group: Group = Group.COMMANDS, + section: int = 0, + order: int = 0, enabled: bool = True, ): """ Create a new Command. + Commands may not use all the arguments - for example, on some platforms, menus + will contain icons; on other platforms they won't. + :param action: A handler that will be invoked when the command is activated. - :param text: A text label for the command. + :param text: A label for the command. :param shortcut: A key combination that can be used to invoke the command. - :param tooltip: A short description for what the command will do. + :param tooltip: A short description of what the command will do. :param icon: The icon, or icon resource, that can be used to decorate the command if the platform requires. - :param group: The group of commands to which this command belongs. If no group - is specified, a default "Command" group will be used. - :param section: An integer describing the section within the group where the - command should appear. If no section is specified, the command will be - allocated to section 0 within the group. - :param order: An integer that can be used to provide sorting order for commands. - Commands will be sorted according to order inside their section; if a - Command doesn't have an order, it will be sorted alphabetically by text within its section. + :param group: The group to which this command belongs. + :param section: The section where the command should appear within its group. + :param order: The position where the command should appear within its section. + If multiple items have the same group, section and order, they will be + sorted alphabetically by their text. :param enabled: Is the Command currently enabled? """ self.text = text @@ -193,9 +196,9 @@ def __init__( self.tooltip = tooltip self.icon = icon - self.group = group if group else Group.COMMANDS - self.section = section if section else 0 - self.order = order if order else 0 + self.group = group + self.section = section + self.order = order self.action = wrapped_handler(self, action) @@ -227,8 +230,8 @@ def enabled(self, value: bool): def icon(self) -> Icon | None: """The Icon for the command. - When specifying the icon, you can provide an icon instance, or a string resource - that can be resolved to an icon. + When setting the icon, you can provide either an :any:`Icon` instance, or a + path that will be passed to the ``Icon`` constructor. """ return self._icon diff --git a/core/src/toga/window.py b/core/src/toga/window.py index d88f3f381d..d09d3af1fa 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -2,11 +2,11 @@ import warnings from builtins import id as identifier -from collections.abc import Mapping +from collections.abc import Mapping, MutableSet from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, overload -from toga.command import CommandSet +from toga.command import Command, CommandSet from toga.handlers import AsyncResult, wrapped_handler from toga.platform import get_platform_factory from toga.widgets.base import WidgetRegistry @@ -197,7 +197,7 @@ def minimizable(self) -> bool: return self._minimizable @property - def toolbar(self) -> CommandSet: + def toolbar(self) -> MutableSet[Command]: """Toolbar for the window.""" return self._toolbar diff --git a/core/tests/command/conftest.py b/core/tests/command/conftest.py index 804ecbe30d..de127dd3b3 100644 --- a/core/tests/command/conftest.py +++ b/core/tests/command/conftest.py @@ -5,7 +5,7 @@ @pytest.fixture def parent_group_1(): - return toga.Group("P", 1) + return toga.Group("P", order=1) @pytest.fixture @@ -20,9 +20,9 @@ def child_group_2(parent_group_1): @pytest.fixture def parent_group_2(): - return toga.Group("O", 2) + return toga.Group("O", order=2) @pytest.fixture def child_group_3(parent_group_2): - return toga.Group("A", 2, parent=parent_group_2) + return toga.Group("A", order=2, parent=parent_group_2) diff --git a/core/tests/command/test_group.py b/core/tests/command/test_group.py index a80b839e61..e4519aa8ae 100644 --- a/core/tests/command/test_group.py +++ b/core/tests/command/test_group.py @@ -69,7 +69,7 @@ def test_group_eq(): """Groups can be compared for equality.""" group_a = toga.Group("A") group_b = toga.Group("B") - group_a1 = toga.Group("A", 1) + group_a1 = toga.Group("A", order=1) # Assign None to variable to trick flake8 into not giving an E711 other = None @@ -79,7 +79,7 @@ def test_group_eq(): # Same values are equal assert group_a == toga.Group("A") - assert group_a1 == toga.Group("A", 1) + assert group_a1 == toga.Group("A", order=1) # Different values are not equal assert group_a != group_b @@ -87,8 +87,8 @@ def test_group_eq(): # Partially same values are not equal assert group_a1 != group_a - assert group_a1 != toga.Group("B", 1) - assert group_a1 != toga.Group("A", 2) + assert group_a1 != toga.Group("B", order=1) + assert group_a1 != toga.Group("A", order=2) def test_parent_creation(): @@ -250,7 +250,7 @@ def test_order_by_text(): def test_order_by_number(): """Groups are ordered by number""" - assert_order(toga.Group("B", 1), toga.Group("A", 2)) + assert_order(toga.Group("B", order=1), toga.Group("A", order=2)) def test_order_by_groups(parent_group_1, parent_group_2): diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index d9eeb4fda3..ca4fb73d1f 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -14,90 +14,24 @@ A representation of app functionality that the user can invoke from menus or too Usage ----- -A GUI requires more than just widgets laid out in a user interface - you'll also want to -allow the user to actually *do* something. In Toga, you do this using -:class:`~toga.Command`. +Aside from event handlers on widgets, most GUI toolkits also provide other ways for +the user to give instructions to an app. In Toga, these UI patterns are supported +by the :class:`~toga.Command` class. A command encapsulates a piece of functionality that the user can invoke - no matter how they invoke it. It doesn't matter if they select a menu item, press a button on a toolbar, or use a key combination - the functionality is wrapped up in a Command. -When a command is added to an application, Toga takes control of ensuring that the -command is exposed to the user in a way that they can access it. On desktop platforms, +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 +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. -Commands can then be organized into a :class:`~toga.Group` of similar commands; inside a -group, commands can be organized into sections. Groups are also hierarchical, so a group -can contain a sub-group, which can contain a sub-group, and so on. +Commands can be organized into a :class:`~toga.Group` of similar commands. Groups are +hierarchical, so a group can contain a sub-group, which can contain a sub-group, and so +on. Inside a group, commands can be organized into sections. -A collection of groups and commands is called a :class:`~toga.CommandSet`. The menus of -an app and the toolbar on a window are both examples of CommandSets. - -Defining Commands -~~~~~~~~~~~~~~~~~ - -When you specify a :class:`~toga.Command`, you provide some additional metadata to help -classify and organize the commands in your application: - -* **action**: A handler to invoke when the command is activated. - -* **text**: A short label for the command. - -* **tooltip**: A short description of what the command will do - -* **shortcut**: (optional) A key combination that can be used to invoke the command. - -* **icon**: (optional) A path to an icon resource to decorate the command. - -* **group**: (optional) A :class:`~toga.Group` object describing a collection of similar commands. - If no group is specified, a default "Command" group will be used. - -* **section**: (optional) An integer providing a sub-grouping. If no section is - specified, the command will be allocated to section 0 within the group. - -* **order**: (optional) An integer indicating where a command falls within a section. - If a :class:`~toga.Command` doesn't have an order, it will be sorted alphabetically by label - within its section. - -Commands may not use all the metadata - for example, on some platforms, menus -will contain icons; on other platforms they won't. Toga will use the metadata -if it is provided, but ignore it (or substitute an appropriate default) if it -isn't. - -Commands can be enabled and disabled; if you disable a command, it will -automatically disable any toolbar or menu item where the command appears. - -Defining Groups -~~~~~~~~~~~~~~~ - -When you specify a :class:`~toga.Group`, you provide some additional metadata to help -classify and organize the commands in your application: - -* **text**: A short label for the group. - -* **section**: (optional) An integer providing a sub-grouping. If no section is - specified, the command will be allocated to section 0 within the group. - -* **order**: (optional) An integer indicating where a command falls within a section. - If a :class:`~toga.Command` doesn't have an order, it will be sorted alphabetically by label - within its section. - -* **parent**: (optional) The parent :class:`~toga.Group` of this group (if any). - -Toga provides a number of ready-to-use groups: - -* ``Group.APP`` - Application level control -* ``Group.FILE`` - File commands -* ``Group.EDIT`` - Editing commands -* ``Group.VIEW`` - Commands to alter the appearance of content -* ``Group.COMMANDS`` - A default group for user-provided commands -* ``Group.WINDOW`` - Commands for managing windows in the app -* ``Group.HELP`` - Help content - -Example -~~~~~~~ - -The following is an example of using menus and commands: +For example: .. code-block:: python @@ -132,16 +66,21 @@ The definitions for ``cmd2``, ``cmd3``, and ``cmd4`` have been omitted, but woul a similar pattern. It doesn't matter what order you add commands to the app - the group, section and order -will be used to put the commands in the right order. +will be used to display the commands in the right order. If a command is added to a toolbar, it will automatically be added to the app as well. It isn't possible to have functionality exposed on a toolbar that isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though it wasn't explicitly added to the app commands. + Reference --------- -.. autoprotocol:: toga.command.ActionHandler - .. autoclass:: toga.Command + :exclude-members: key + +.. autoclass:: toga.Group + :exclude-members: key + +.. autoprotocol:: toga.command.ActionHandler From 64064b8c3023bcefccf549fac0eef8ddb4253341 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 26 Oct 2023 17:51:25 +0100 Subject: [PATCH 41/66] DocumentApp documentation cleanups --- core/src/toga/app.py | 14 +++++--------- core/src/toga/documents.py | 29 +++++++++++++++++++---------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index a9f2e4489d..bc3aef4a8a 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -693,12 +693,7 @@ def __init__( exception that there is no main window. Instead, each document managed by the app will create and manage it's own window (or windows). - :param document_types: A dictionary of file extensions of document types that - the application can managed, mapping to the :class:`toga.Document` subclass - that will be created when a document of with that extension is opened. The - :class:`toga.Document` subclass must take exactly 2 arguments in it's - constructor: ``path`` and ``app`` - + :param document_types: Initial :any:`document_types` mapping. """ if document_types is None: raise ValueError("A document must manage at least one document type.") @@ -732,9 +727,10 @@ def _verify_startup(self): def document_types(self) -> dict[str, type[Document]]: """The document types this app can manage. - This is a dictionary of file extensions mapping to the Document class that will - be created when a document of with that extension is opened. This class will - usually be a subclass of :class:`toga.Document`.toga.Document`. + 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 diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index 271a5c6006..a4a02dd7c7 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from toga.app import App + from toga.window import Window class Document(ABC): @@ -25,8 +26,8 @@ def __init__( """ self._path = Path(path) self._document_type = document_type - self._app = app + self._main_window = None # Create the visual representation of the document self.create() @@ -38,8 +39,7 @@ def can_close(self) -> bool: """Is the main document window allowed to close? The default implementation always returns ``True``; subclasses can override this - implementation to provide protection against losing unsaved document changes, or - other close-preventing behavior. + 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. @@ -69,7 +69,7 @@ async def handle_close(self, window, **kwargs): @property def path(self) -> Path: - """The path where the document is stored.""" + """The path where the document is stored (read-only).""" return self._path @property @@ -83,24 +83,33 @@ def filename(self) -> Path: @property def document_type(self) -> Path: - """A human-readable description of the document type.""" + """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.""" + """The app that this document is associated with (read-only).""" return self._app + @property + def main_window(self) -> Window: + """The main window for the document.""" + return self._main_window + + @main_window.setter + def main_window(self, window): + self._main_window = window + def show(self) -> None: - """Show the main_window for this document.""" + """Show the :any:`main_window` for this document.""" self.main_window.show() @abstractmethod def create(self) -> None: - """Create the window (or window) containers for the document. + """Create the window (or windows) for the document. - This must, at a minimum, assign a ``main_window`` property to the document. It - may create additional windows or visual representations, if desired. + This method must, at a minimum, assign the :any:`main_window` property. It + may also create additional windows or UI elements if desired. """ @abstractmethod From a8df43091115082841cf52a4faa3ebb6306993fc Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 27 Oct 2023 15:56:41 +0800 Subject: [PATCH 42/66] Correct GTK file dialog usage in DocumentApp. --- gtk/src/toga_gtk/app.py | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 57e99bbe74..a5aa61944e 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -2,7 +2,6 @@ import signal import sys from pathlib import Path -from urllib.parse import unquote, urlparse import gbulb @@ -274,39 +273,24 @@ def gtk_startup(self, data=None): try: # Look for a filename specified on the command line - path = Path(sys.argv[1]) + self.interface._open(Path(sys.argv[1])) except IndexError: # Nothing on the command line; open a file dialog instead. - # TODO: This causes a blank window to be shown. - # Is there a way to open a file dialog without having a window? + # Create a temporary window so we have context for the dialog m = toga.Window() - path = m.open_file_dialog( + 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(), ) - self.open_document(path) - def open_file(self, widget, **kwargs): - # TODO: This causes a blank window to be shown. - # Is there a way to open a file dialog without having a window? + # Create a temporary window so we have context for the dialog m = toga.Window() - path = m.open_file_dialog( + 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, ) - - self.open_document(path) - - def open_document(self, fileURL): - """Open a new document in this app. - - Args: - fileURL (str): The URL/path to the file to add as a document. - """ - # Convert the fileURL to a file path. - fileURL = fileURL.rstrip("/") - path = Path(unquote(urlparse(fileURL).path)) - - # Create the document instance - self.interface._open(path) From 1029f33db18ae8cecd4f1427df3497126b6b6957 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 27 Oct 2023 14:23:15 +0100 Subject: [PATCH 43/66] examples/command working correctly on WinForms --- core/src/toga/app.py | 18 +++++++++++++++--- core/src/toga/command.py | 3 ++- docs/reference/api/resources/command.rst | 5 ----- examples/command/command/app.py | 12 ++++++------ winforms/src/toga_winforms/app.py | 21 ++++++++++++++------- winforms/src/toga_winforms/container.py | 2 +- winforms/src/toga_winforms/window.py | 12 ++++++------ 7 files changed, 44 insertions(+), 29 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index bc3aef4a8a..21f4537a7d 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -275,6 +275,7 @@ def __init__( warn( "App.app_name has been renamed to distribution_name", DeprecationWarning, + stacklevel=2, ) distribution_name = app_name @@ -282,6 +283,7 @@ def __init__( warn( "App.id is deprecated and will be ignored. Use app_id instead", DeprecationWarning, + stacklevel=2, ) if windows is not None: @@ -419,7 +421,11 @@ def paths(self) -> Paths: @property def name(self) -> str: """**DEPRECATED** – Use :any:`formal_name`.""" - warn("App.name is deprecated. Use formal_name instead", DeprecationWarning) + warn( + "App.name is deprecated. Use formal_name instead", + DeprecationWarning, + stacklevel=2, + ) return self._formal_name @property @@ -436,7 +442,11 @@ def distribution_name(self) -> str: @property def app_name(self) -> str: """**DEPRECATED** – Renamed to ``distribution_name``.""" - warn("App.app_name has been renamed to distribution_name", DeprecationWarning) + warn( + "App.app_name has been renamed to distribution_name", + DeprecationWarning, + stacklevel=2, + ) return self._distribution_name @property @@ -471,7 +481,9 @@ def description(self) -> str | None: @property def id(self) -> str: """**DEPRECATED** – Use :any:`app_id`.""" - warn("App.id is deprecated. Use app_id instead", DeprecationWarning) + warn( + "App.id is deprecated. Use app_id instead", DeprecationWarning, stacklevel=2 + ) return self._app_id @property diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 0ee13441d2..6845865aeb 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -177,7 +177,8 @@ def __init__( Commands may not use all the arguments - for example, on some platforms, menus will contain icons; on other platforms they won't. - :param action: A handler that will be invoked when the command is activated. + :param action: A handler to invoke when the command is activated. If this is + ``None``, the command will be disabled. :param text: A label for the command. :param shortcut: A key combination that can be used to invoke the command. :param tooltip: A short description of what the command will do. diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index ca4fb73d1f..b5abfa53d0 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -68,11 +68,6 @@ a similar pattern. It doesn't matter what order you add commands to the app - the group, section and order will be used to display the commands in the right order. -If a command is added to a toolbar, it will automatically be added to the app -as well. It isn't possible to have functionality exposed on a toolbar that -isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though -it wasn't explicitly added to the app commands. - Reference --------- diff --git a/examples/command/command/app.py b/examples/command/command/app.py index 259a1f6793..327e6ebaff 100644 --- a/examples/command/command/app.py +++ b/examples/command/command/app.py @@ -45,10 +45,9 @@ def startup(self): tiberius_icon_256 = "resources/tiberius-256" # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow(title=self.formal_name) # Add commands - print("adding commands") # Create a "Things" menu group to contain some of the commands. # No explicit ordering is provided on the group, so it will appear # after application-level menus, but *before* the Command group. @@ -113,16 +112,18 @@ def startup(self): ) cmd7 = toga.Command( self.action7, - text="TB action 7", + text="TB Action 7", tooltip="Perform toolbar action 7", + shortcut=toga.Key.MOD_1 + "p", order=30, icon=tiberius_icon_256, group=sub_menu, + enabled=False, ) def action4(widget): print("action 4") - cmd3.enabled = not cmd3.enabled + cmd7.enabled = not cmd7.enabled self.textpanel.value += "action 4\n" cmd4 = toga.Command( @@ -135,7 +136,7 @@ def action4(widget): # The order in which commands are added to the app or the toolbar won't # alter anything. Ordering is defined by the command definitions. - self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3) + self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3, cmd7) self.app.main_window.toolbar.add(cmd2, cmd5, cmd7) # Buttons @@ -164,7 +165,6 @@ def action4(widget): def main(): - print("app.main") return ExampleTestCommandApp("Test Command", "org.beeware.widgets.command") diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 5e46c232c3..ca6be66ccf 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -110,16 +110,27 @@ def create(self): # Call user code to populate the main window self.interface._startup() - self.create_menus() self._create_app_commands() + self.create_menus() self.interface.main_window._impl.set_app(self) def create_menus(self): + 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". + self._menu_items = {} self._menu_groups = {} toga.Group.FILE.order = 0 - menubar = WinForms.MenuStrip() submenu = None for cmd in self.interface.commands: if cmd == GROUP_BREAK: @@ -145,11 +156,7 @@ def create_menus(self): self._menu_items[item] = cmd submenu.DropDownItems.Add(item) - # The menu bar doesn't need to be positioned, because its `Dock` property - # defaults to `Top`. - self.interface.main_window._impl.native.Controls.Add(menubar) - self.interface.main_window._impl.native.MainMenuStrip = menubar - self.interface.main_window._impl.resize_content() + window.resize_content() def _submenu(self, group, menubar): try: diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index 061ef38d83..dcddf391f6 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -52,7 +52,7 @@ def apply_layout(self, layout_width, layout_height): ) def add_content(self, widget): - # The default appears to be to add new controls to the back of the Z-order. + # The default is to add new controls to the back of the Z-order. self.native_content.Controls.Add(widget.native) widget.native.BringToFront() diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 660178133b..eb985ff0e9 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -47,6 +47,7 @@ def create_toolbar(self): # 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". for cmd in self.interface.toolbar: if cmd == GROUP_BREAK: @@ -54,13 +55,12 @@ def create_toolbar(self): elif cmd == SECTION_BREAK: item = WinForms.ToolStripSeparator() else: + item = WinForms.ToolStripMenuItem(cmd.text) + if cmd.tooltip is not None: + item.ToolTipText = cmd.tooltip if cmd.icon is not None: - native_icon = cmd.icon._impl.native - item = WinForms.ToolStripMenuItem( - cmd.text, native_icon.ToBitmap() - ) - else: - item = WinForms.ToolStripMenuItem(cmd.text) + item.Image = cmd.icon._impl.native.ToBitmap() + item.Enabled = cmd.enabled item.Click += WeakrefCallable(cmd._impl.winforms_handler) cmd._impl.native.append(item) self.toolbar_native.Items.Add(item) From d185acf103b26cb3d5d0e967e9d8283caa408928 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 27 Oct 2023 19:33:39 +0100 Subject: [PATCH 44/66] WinForms passing all tests --- cocoa/src/toga_cocoa/app.py | 5 -- cocoa/tests_backend/app.py | 2 +- core/src/toga/app.py | 5 +- gtk/src/toga_gtk/app.py | 7 +- gtk/tests_backend/app.py | 2 +- testbed/src/testbed/app.py | 2 +- testbed/tests/test_app.py | 19 +++-- winforms/src/toga_winforms/app.py | 32 +++++---- winforms/src/toga_winforms/keys.py | 39 ++--------- winforms/tests_backend/app.py | 108 ++++++++++++++++++++++++++++- winforms/tests_backend/window.py | 36 +++++++++- 11 files changed, 187 insertions(+), 70 deletions(-) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 39efa2a087..82c67d7ea2 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -436,11 +436,6 @@ def set_current_window(self, window): window._impl.native.makeKeyAndOrderFront(window._impl.native) def enter_full_screen(self, windows): - # If we're already in full screen mode, exit so that - # we can re-assign windows to screens. - if self.interface.is_full_screen: - self.interface.exit_full_screen() - opts = NSMutableDictionary.alloc().init() opts.setObject( NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 69e39fd21c..4ef89ca77a 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -99,7 +99,7 @@ def activate_menu_exit(self): def activate_menu_about(self): self._activate_menu_item(["*", "About Toga Testbed"]) - def close_about_dialog(self): + async def close_about_dialog(self): about_dialog = self.app._impl.native.keyWindow if isinstance(about_dialog, NSPanel): about_dialog.close() diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 21f4537a7d..2aa4b2371e 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -561,9 +561,8 @@ def set_full_screen(self, *windows: Window) -> None: those windows will not be visible. If no windows are specified, the app will exit full screen mode. """ - if not windows: - self.exit_full_screen() - else: + self.exit_full_screen() + if windows: self._impl.enter_full_screen(windows) self._full_screen_windows = windows diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index a5aa61944e..61217f0a36 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -239,11 +239,8 @@ def set_current_window(self, window): window._impl.native.present() def enter_full_screen(self, windows): - for window in self.interface.windows: - if window in windows: - window._impl.set_full_screen(True) - else: - window._impl.set_full_screen(False) + for window in windows: + window._impl.set_full_screen(True) def exit_full_screen(self, windows): for window in windows: diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 9c87786483..06d263e9c7 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -91,7 +91,7 @@ def activate_menu_exit(self): def activate_menu_about(self): self._activate_menu_item(["Help", "About Toga Testbed"]) - def close_about_dialog(self): + async def close_about_dialog(self): self.app._impl._close_about(self.app._impl.native_about_dialog) def activate_menu_visit_homepage(self): diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 506c70b5aa..f4e7d4af36 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -90,4 +90,4 @@ def startup(self): def main(): - return Testbed(app_name="testbed") + return Testbed(distribution_name="testbed") diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 085db28f95..2e147604fa 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -303,7 +303,7 @@ async def test_show_hide_cursor(app, app_probe): await app_probe.redraw("Cursor is visible") assert app_probe.is_cursor_visible - # Hiding again can't make it more hidden + # Showing again can't make it more visible app.show_cursor() await app_probe.redraw("Cursor is still visible") assert app_probe.is_cursor_visible @@ -345,8 +345,12 @@ async def test_current_window(app, app_probe): async def test_main_window_toolbar(app, main_window, main_window_probe): """A toolbar can be added to a main window""" - # Add some items to the main window toolbar - main_window.toolbar.add(app.cmd1, app.cmd2, app.cmd3, app.cmd4) + # Add some items to show the toolbar + assert not main_window_probe.has_toolbar() + main_window.toolbar.add(app.cmd1, app.cmd2) + + # Add some more items to an existing toolbar + main_window.toolbar.add(app.cmd3, app.cmd4) await main_window_probe.redraw("Main window has a toolbar") assert main_window_probe.has_toolbar() @@ -415,6 +419,11 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): await main_window_probe.redraw("Main window has no toolbar") assert not main_window_probe.has_toolbar() + # Removing it again should have no effect + main_window.toolbar.clear() + await main_window_probe.redraw("Main window has no toolbar") + assert not main_window_probe.has_toolbar() + async def test_system_menus(app_probe): """System-specific menus behave as expected""" @@ -428,7 +437,7 @@ async def test_menu_about(monkeypatch, app, app_probe): # When in CI, Cocoa needs a little time to guarantee the dialog is displayed. await app_probe.redraw("About dialog shown", delay=0.1) - app_probe.close_about_dialog() + await app_probe.close_about_dialog() await app_probe.redraw("About dialog destroyed") # Make the app definition minimal to verify the dialog still displays @@ -441,7 +450,7 @@ async def test_menu_about(monkeypatch, app, app_probe): # When in CI, Cocoa needs a little time to guarantee the dialog is displayed. await app_probe.redraw("About dialog with no details shown", delay=0.1) - app_probe.close_about_dialog() + await app_probe.close_about_dialog() await app_probe.redraw("About dialog with no details destroyed") diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index ca6be66ccf..3bcf9ec6b1 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -115,6 +115,11 @@ def create(self): self.interface.main_window._impl.set_app(self) 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: @@ -127,10 +132,10 @@ def create_menus(self): window.native.MainMenuStrip = menubar menubar.SendToBack() # In a dock, "back" means "top". - self._menu_items = {} + # The File menu should come before all user-created menus. self._menu_groups = {} + toga.Group.FILE.order = -1 - toga.Group.FILE.order = 0 submenu = None for cmd in self.interface.commands: if cmd == GROUP_BREAK: @@ -139,21 +144,14 @@ def create_menus(self): submenu.DropDownItems.Add("-") else: submenu = self._submenu(cmd.group, menubar) - item = WinForms.ToolStripMenuItem(cmd.text) - if cmd.action: item.Click += WeakrefCallable(cmd._impl.winforms_handler) - item.Enabled = cmd.enabled - if cmd.shortcut is not None: - shortcut_keys = toga_to_winforms_key(cmd.shortcut) - item.ShortcutKeys = shortcut_keys - item.ShowShortcutKeys = True + item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) + item.Enabled = cmd.enabled cmd._impl.native.append(item) - - self._menu_items[item] = cmd submenu.DropDownItems.Add(item) window.resize_content() @@ -180,20 +178,26 @@ def _submenu(self, group, menubar): 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), - # Quit should always be the last item, in a section on its own + # + # 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(None), - "Exit", # A Windows exit command doesn't usually contain the app name. + "Exit", shortcut=Key.MOD_1 + "q", group=toga.Group.FILE, section=sys.maxsize, ), + # toga.Command( lambda _: self.interface.visit_homepage(), "Visit homepage", @@ -310,7 +314,7 @@ def set_main_window(self, window): def get_current_window(self): for window in self.interface.windows: if WinForms.Form.ActiveForm == window._impl.native: - return window._impl.native + return window._impl def set_current_window(self, window): window._impl.native.Activate() diff --git a/winforms/src/toga_winforms/keys.py b/winforms/src/toga_winforms/keys.py index 5fabd663f0..a8f204cc78 100644 --- a/winforms/src/toga_winforms/keys.py +++ b/winforms/src/toga_winforms/keys.py @@ -27,6 +27,9 @@ for letter in ascii_uppercase } ) +WINFORMS_KEYS_MAP.update( + {str(digit): getattr(WinForms.Keys, f"D{digit}") for digit in range(10)} +) def toga_to_winforms_key(key): @@ -35,38 +38,10 @@ def toga_to_winforms_key(key): if modifier.value in key: codes.append(modifier_code) key = key.replace(modifier.value, "") - key_code = WINFORMS_KEYS_MAP.get(key, None) - if key_code is not None: - codes.append(key_code) - return reduce(operator.or_, codes) - - -TOGA_KEYS_MAP = {w: t for t, w in WINFORMS_KEYS_MAP.items()} -TOGA_KEYS_MAP.update( - { - getattr(WinForms.Keys, modifier.title()): getattr(Key, modifier.upper()) - for modifier in ["shift", "up", "down", "left", "right", "home"] - } -) - -def toga_key(event): - """Convert a Cocoa NSKeyEvent into a Toga event.""" try: - key = TOGA_KEYS_MAP[event.KeyCode] - except KeyError: - key = WINFORMS_NON_PRINTABLES_MAP - modifiers = set() - - # if event.Capslock?: - # modifiers.add(Key.CAPSLOCK) - if event.Shift: - modifiers.add(Key.SHIFT) - if event.Control: - modifiers.add(Key.MOD_1) - if event.Alt: - modifiers.add(Key.MOD_2) - # if event.Windows?: - # modifiers.add(Key.MOD_3) + codes.append(WINFORMS_KEYS_MAP[key]) + except KeyError: # pragma: no cover + raise ValueError(f"unknown key: {key!r}") from None - return {"key": key, "modifiers": modifiers} + return reduce(operator.or_, codes) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 6059da9865..9652520f90 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -1,16 +1,23 @@ +import ctypes from pathlib import Path +from time import sleep -import System.Windows.Forms as WinForms +import pytest +from System import EventArgs +from System.Drawing import Point +from System.Windows.Forms import Application, Cursor from .probe import BaseProbe +from .window import WindowProbe class AppProbe(BaseProbe): def __init__(self, app): super().__init__() self.app = app + self.main_window = app.main_window # The Winforms Application class is a singleton instance - assert self.app._impl.native == WinForms.Application + assert self.app._impl.native == Application @property def config_path(self): @@ -45,3 +52,100 @@ def logs_path(self): return ( Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" / "Logs" ) + + @property + def is_cursor_visible(self): + # Despite what the documentation says, Cursor.Current never returns null in + # Windows 10, whether the cursor is over the window or not. + # + # The following code is based on https://stackoverflow.com/a/12467292, but it + # only works when the cursor is over the window. + form = self.main_window._impl.native + Cursor.Position = Point( + form.Location.X + (form.Size.Width // 2), + form.Location.Y + (form.Size.Height // 2), + ) + + # A small delay is apparently required for the new position to take effect. + sleep(0.1) + + class POINT(ctypes.Structure): + _fields_ = [ + ("x", ctypes.c_long), + ("y", ctypes.c_long), + ] + + class CURSORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("hCursor", ctypes.c_void_p), + ("ptScreenPos", POINT), + ] + + GetCursorInfo = ctypes.windll.user32.GetCursorInfo + GetCursorInfo.argtypes = [ctypes.POINTER(CURSORINFO)] + + info = CURSORINFO() + info.cbSize = ctypes.sizeof(info) + if not GetCursorInfo(ctypes.byref(info)): + raise RuntimeError("GetCursorInfo failed") + return info.flags == 1 + + def is_full_screen(self, window): + return WindowProbe(self.app, window).is_full_screen + + def content_size(self, window): + return WindowProbe(self.app, window).content_size + + def _menu_item(self, path): + item = self.main_window._impl.native.MainMenuStrip + for i, label in enumerate(path): + children = getattr(item, "Items" if i == 0 else "DropDownItems") + child_labels = [child.Text for child in children] + try: + child_index = child_labels.index(label) + except ValueError: + raise AssertionError( + f"no item named {path[:i+1]}; options are {child_labels}" + ) from None + item = children[child_index] + + return item + + def _activate_menu_item(self, path): + self._menu_item(path).OnClick(EventArgs.Empty) + + def activate_menu_exit(self): + self._activate_menu_item(["File", "Exit"]) + + def activate_menu_about(self): + self._activate_menu_item(["Help", "About Toga Testbed"]) + + async def close_about_dialog(self): + await WindowProbe(self.app, self.main_window)._close_dialog("\n") + + def activate_menu_visit_homepage(self): + self._activate_menu_item(["Help", "Visit homepage"]) + + def assert_menu_item(self, path, *, enabled=True): + assert self._menu_item(path).Enabled == enabled + + def assert_system_menus(self): + self.assert_menu_item(["File", "Preferences"], enabled=False) + self.assert_menu_item(["File", "Exit"]) + + self.assert_menu_item(["Help", "Visit homepage"]) + self.assert_menu_item(["Help", "About Toga Testbed"]) + + def activate_menu_close_window(self): + pytest.xfail("This platform doesn't have a window management menu") + + def activate_menu_close_all_windows(self): + pytest.xfail("This platform doesn't have a window management menu") + + def activate_menu_minimize(self): + pytest.xfail("This platform doesn't have a window management menu") + + def keystroke(self, combination): + pytest.xfail("Not applicable to this backend") diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index f560aca484..ec3bac6d48 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -1,7 +1,15 @@ import asyncio from unittest.mock import Mock -from System.Windows.Forms import Form, FormBorderStyle, FormWindowState +from System import EventArgs +from System.Windows.Forms import ( + Form, + FormBorderStyle, + FormWindowState, + MenuStrip, + ToolStrip, + ToolStripSeparator, +) from .probe import BaseProbe @@ -111,3 +119,29 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): else: dialog.native.SelectedPath = str(result[-1] if multiple_select else result) await self._close_dialog("\n") + + def _native_toolbar(self): + for control in self.native.Controls: + if isinstance(control, ToolStrip) and not isinstance(control, MenuStrip): + return control + else: + return None + + def has_toolbar(self): + return self._native_toolbar() is not None + + def _native_toolbar_item(self, index): + return self._native_toolbar().Items[index] + + def assert_is_toolbar_separator(self, index, section=False): + assert isinstance(self._native_toolbar_item(index), ToolStripSeparator) + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self._native_toolbar_item(index) + assert item.Text == label + assert item.ToolTipText == tooltip + assert (item.Image is not None) == has_icon + assert item.Enabled == enabled + + def press_toolbar_button(self, index): + self._native_toolbar_item(index).OnClick(EventArgs.Empty) From 3938b1e890d3576777bdb7ea8f14c8ca28b8ab86 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 28 Oct 2023 15:40:51 +0100 Subject: [PATCH 45/66] WinForms app.py at 100% coverage --- iOS/src/toga_iOS/window.py | 2 +- testbed/tests/test_app.py | 9 ++++- winforms/src/toga_winforms/app.py | 55 +++++++++++-------------------- winforms/tests_backend/app.py | 6 ++++ 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 24aca68a48..942b2ddccd 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -81,7 +81,7 @@ def set_app(self, app): pass def create_toolbar(self): - pass + pass # pragma: no cover def show(self): self.native.makeKeyAndVisible() diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 2e147604fa..f161f76161 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -289,6 +289,7 @@ async def test_full_screen(app, app_probe): async def test_show_hide_cursor(app, app_probe): """The app cursor can be hidden and shown""" + assert app_probe.is_cursor_visible app.hide_cursor() await app_probe.redraw("Cursor is hidden") assert not app_probe.is_cursor_visible @@ -308,8 +309,14 @@ async def test_show_hide_cursor(app, app_probe): await app_probe.redraw("Cursor is still visible") assert app_probe.is_cursor_visible - async def test_current_window(app, app_probe): + async def test_current_window(app, app_probe, main_window): """The current window can be retrieved.""" + assert app.current_window == main_window + main_window.hide() + assert app.current_window is None + main_window.show() + assert app.current_window == main_window + try: window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 3bcf9ec6b1..994b3686f9 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -24,7 +24,7 @@ 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: + 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. @@ -71,7 +71,7 @@ def create(self): # SetProcessDpiAwareness(True) if (win_version.Major == 6 and win_version.Minor == 3) or ( win_version.Major == 10 and win_version.Build < 15063 - ): + ): # pragma: no cover windll.shcore.SetProcessDpiAwareness(True) print( "WARNING: Your Windows version doesn't support DPI-independent rendering. " @@ -82,7 +82,7 @@ def create(self): elif win_version.Major == 10 and win_version.Build >= 15063: windll.user32.SetProcessDpiAwarenessContext(-2) # Any other version of windows should use SetProcessDPIAware() - else: + else: # pragma: no cover windll.user32.SetProcessDPIAware() self.native.EnableVisualStyles() @@ -95,14 +95,14 @@ def create(self): # encouraged. try: ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12 - except AttributeError: + except AttributeError: # pragma: no cover print( "WARNING: Your Windows .NET install does not support TLS1.2. " "You may experience difficulties accessing some web server content." ) try: ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls13 - except AttributeError: + except AttributeError: # pragma: no cover print( "WARNING: Your Windows .NET install does not support TLS1.3. " "You may experience difficulties accessing some web server content." @@ -145,8 +145,7 @@ def create_menus(self): else: submenu = self._submenu(cmd.group, menubar) item = WinForms.ToolStripMenuItem(cmd.text) - if cmd.action: - item.Click += WeakrefCallable(cmd._impl.winforms_handler) + item.Click += WeakrefCallable(cmd._impl.winforms_handler) if cmd.shortcut is not None: item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) item.Enabled = cmd.enabled @@ -206,13 +205,7 @@ def _create_app_commands(self): ), ) - def open_document(self, fileURL): - """Add a new document to this app.""" - print( - "STUB: If you want to handle opening documents, implement App.open_document(fileURL)" - ) - - def winforms_thread_exception(self, sender, winforms_exc): + def winforms_thread_exception(self, sender, winforms_exc): # pragma: no cover # The PythonException returned by Winforms doesn't give us # easy access to the underlying Python stacktrace; so we # reconstruct it from the string message. @@ -239,13 +232,13 @@ def winforms_thread_exception(self, sender, winforms_exc): print(py_exc.Message) @classmethod - def print_stack_trace(cls, stack_trace_line): + def print_stack_trace(cls, stack_trace_line): # pragma: no cover for level in stack_trace_line.split("', '"): for line in level.split("\\n"): if line: print(line) - def run_app(self): + def run_app(self): # pragma: no cover # Enable coverage tracing on this non-Python-created thread # (https://github.com/nedbat/coveragepy/issues/686). if threading._trace_hook: @@ -278,20 +271,17 @@ def main_loop(self): # If it's non-None, raise it, as it indicates the underlying # app thread had a problem; this is effectibely a re-raise over # a thread boundary. - if self._exception: + if self._exception: # pragma: no cover raise self._exception def show_about_dialog(self): message_parts = [] - if self.interface.formal_name is not None: - if self.interface.version is not None: - message_parts.append( - f"{self.interface.formal_name} v{self.interface.version}" - ) - else: - message_parts.append(self.interface.formal_name) - elif self.interface.version is not None: - message_parts.append(f"v{self.interface.version}") + if self.interface.version is not None: + message_parts.append( + f"{self.interface.formal_name} v{self.interface.version}" + ) + else: + message_parts.append(self.interface.formal_name) if self.interface.author is not None: message_parts.append(f"Author: {self.interface.author}") @@ -304,7 +294,7 @@ def show_about_dialog(self): def beep(self): SystemSounds.Beep.Play() - def exit(self): + def exit(self): # pragma: no cover self._is_exiting = True self.native.Exit() @@ -315,6 +305,7 @@ def get_current_window(self): for window in self.interface.windows: if WinForms.Form.ActiveForm == window._impl.native: return window._impl + return None def set_current_window(self, window): window._impl.native.Activate() @@ -338,7 +329,7 @@ def hide_cursor(self): self._cursor_visible = False -class DocumentApp(App): +class DocumentApp(App): # pragma: no cover def _create_app_commands(self): super()._create_app_commands() self.interface.commands.add( @@ -350,11 +341,3 @@ def _create_app_commands(self): section=0, ), ) - - def open_document(self, fileURL): - """Open a new document in this app. - - Args: - fileURL (str): The URL/path to the file to add as a document. - """ - self.interface.factory.not_implemented("DocumentApp.open_document()") diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 9652520f90..d17d857385 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -1,4 +1,5 @@ import ctypes +import os from pathlib import Path from time import sleep @@ -53,6 +54,11 @@ def logs_path(self): Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" / "Logs" ) + # When no mouse is connected, the cursor is hidden by default + # (https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showcursor). + if "CI" in os.environ: + Cursor.Show() + @property def is_cursor_visible(self): # Despite what the documentation says, Cursor.Current never returns null in From 688da9628b156857d3e3c69c327800a81dc2ac9c Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 28 Oct 2023 16:23:13 +0100 Subject: [PATCH 46/66] All WinForms files at 100% coverage --- testbed/tests/widgets/test_base.py | 14 ++++++++++++++ winforms/src/toga_winforms/colors.py | 4 +--- winforms/src/toga_winforms/libs/proactor.py | 12 ++++++------ winforms/src/toga_winforms/libs/wrapper.py | 4 ++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 00facca865..67ca42fb06 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -153,3 +153,17 @@ async def test_parenting(widget, probe): probe.assert_layout(position=(0, 0), size=(100, 200)) other_probe.assert_layout(position=(150, 0), size=(100, 200)) child_probe.assert_layout(position=(100, 0), size=(50, 75)) + + +async def test_tab_index(widget, probe, other): + if toga.platform.current_platform not in {"windows"}: + assert widget.tab_index is None + assert other.tab_index is None + else: + assert widget.tab_index == 1 + assert other.tab_index == 2 + + widget.tab_index = 4 + other.tab_index = 2 + assert widget.tab_index == 4 + assert other.tab_index == 2 diff --git a/winforms/src/toga_winforms/colors.py b/winforms/src/toga_winforms/colors.py index 8609bbebf2..4a7b89d08f 100644 --- a/winforms/src/toga_winforms/colors.py +++ b/winforms/src/toga_winforms/colors.py @@ -1,5 +1,5 @@ from System.Drawing import Color -from travertino.colors import NAMED_COLOR, TRANSPARENT +from travertino.colors import TRANSPARENT CACHE = {TRANSPARENT: Color.Transparent} @@ -8,8 +8,6 @@ def native_color(c): try: color = CACHE[c] except KeyError: - if isinstance(c, str): - c = NAMED_COLOR[c] color = Color.FromArgb( int(c.rgba.a * 255), int(c.rgba.r), int(c.rgba.g), int(c.rgba.b) ) diff --git a/winforms/src/toga_winforms/libs/proactor.py b/winforms/src/toga_winforms/libs/proactor.py index 1aa868baca..53c59b386a 100644 --- a/winforms/src/toga_winforms/libs/proactor.py +++ b/winforms/src/toga_winforms/libs/proactor.py @@ -38,9 +38,9 @@ def run_forever(self, app): # in Lib/ascynio/base_events.py) # === START BaseEventLoop.run_forever() setup === self._check_closed() - if self.is_running(): + if self.is_running(): # pragma: no cover raise RuntimeError("This event loop is already running") - if events._get_running_loop() is not None: + if events._get_running_loop() is not None: # pragma: no cover raise RuntimeError( "Cannot run the event loop while another loop is running" ) @@ -71,13 +71,13 @@ def run_forever(self, app): def enqueue_tick(self): # Queue a call to tick in 5ms. - if not self.app._is_exiting: + if not self.app._is_exiting: # pragma: no branch self.task = Action[Task](self.tick) Task.Delay(5).ContinueWith(self.task) def tick(self, *args, **kwargs): """Cause a single iteration of the event loop to run on the main GUI thread.""" - if not self.app._is_exiting: + if not self.app._is_exiting: # pragma: no branch action = Action(self.run_once_recurring) self.app.app_dispatcher.Invoke(action) @@ -99,7 +99,7 @@ def run_once_recurring(self): # Perform one tick of the event loop. self._run_once() - if self._stopping: + if self._stopping: # pragma: no cover # If we're stopping, we can do the "finally" handling from # the BaseEventLoop run_forever(). # === START BaseEventLoop.run_forever() finally handling === @@ -123,5 +123,5 @@ def run_once_recurring(self): callback(*args) # Exceptions thrown by this method will be silently ignored. - except BaseException: + except BaseException: # pragma: no cover traceback.print_exc() diff --git a/winforms/src/toga_winforms/libs/wrapper.py b/winforms/src/toga_winforms/libs/wrapper.py index 958db4cc66..4ad877d3a4 100644 --- a/winforms/src/toga_winforms/libs/wrapper.py +++ b/winforms/src/toga_winforms/libs/wrapper.py @@ -13,10 +13,10 @@ class WeakrefCallable: def __init__(self, function): try: self.ref = weakref.WeakMethod(function) - except TypeError: + except TypeError: # pragma: no cover self.ref = weakref.ref(function) def __call__(self, *args, **kwargs): function = self.ref() - if function: + if function: # pragma: no branch return function(*args, **kwargs) From 8b831489bf8f0274fa864271b633560685268850 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 28 Oct 2023 16:39:01 +0100 Subject: [PATCH 47/66] Add debugging for cursor visibility --- winforms/tests_backend/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index d17d857385..d0b0bf8f61 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -57,6 +57,7 @@ def logs_path(self): # When no mouse is connected, the cursor is hidden by default # (https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showcursor). if "CI" in os.environ: + print("FIXME show cursor in CI") Cursor.Show() @property @@ -96,6 +97,7 @@ class CURSORINFO(ctypes.Structure): info.cbSize = ctypes.sizeof(info) if not GetCursorInfo(ctypes.byref(info)): raise RuntimeError("GetCursorInfo failed") + print(f"FIXME {info.flags=}") return info.flags == 1 def is_full_screen(self, window): From 500ec7ff1bc94d690ee70f300ab19d07b42da12b Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 28 Oct 2023 16:58:08 +0100 Subject: [PATCH 48/66] More CI debugging --- testbed/tests/test_app.py | 13 ++++++++----- winforms/tests_backend/app.py | 13 +++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index f161f76161..b18da030b9 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -311,11 +311,14 @@ async def test_show_hide_cursor(app, app_probe): async def test_current_window(app, app_probe, main_window): """The current window can be retrieved.""" - assert app.current_window == main_window - main_window.hide() - assert app.current_window is None - main_window.show() - assert app.current_window == main_window + try: + assert app.current_window == main_window + main_window.hide() + assert app.current_window is None + main_window.show() + assert app.current_window == main_window + finally: + main_window.show() try: window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index d0b0bf8f61..8466c5b765 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -1,5 +1,4 @@ import ctypes -import os from pathlib import Path from time import sleep @@ -54,12 +53,6 @@ def logs_path(self): Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" / "Logs" ) - # When no mouse is connected, the cursor is hidden by default - # (https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showcursor). - if "CI" in os.environ: - print("FIXME show cursor in CI") - Cursor.Show() - @property def is_cursor_visible(self): # Despite what the documentation says, Cursor.Current never returns null in @@ -97,8 +90,12 @@ class CURSORINFO(ctypes.Structure): info.cbSize = ctypes.sizeof(info) if not GetCursorInfo(ctypes.byref(info)): raise RuntimeError("GetCursorInfo failed") + print(f"FIXME {info.flags=}") - return info.flags == 1 + # Local testing returns 1 ("the cursor is showing"). The GitHub Actions runner + # returns 2 ("the system is not drawing the cursor because the user is providing + # input through touch or pen instead of the mouse"). + return info.flags in (1, 2) def is_full_screen(self, window): return WindowProbe(self.app, window).is_full_screen From 74f79fecd1f9671d9ac634248e26243700aab881 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 29 Oct 2023 10:26:26 +0000 Subject: [PATCH 49/66] More CI debugging --- .github/workflows/ci.yml | 155 +++++++++++++++++----------------- testbed/tests/test_app.py | 6 +- winforms/tests_backend/app.py | 6 +- 3 files changed, 86 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77d4a20475..0b9313975f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,86 +58,87 @@ jobs: - "web" - "winforms" - core: - runs-on: ${{ matrix.platform }}-latest - needs: [pre-commit, towncrier, package] - continue-on-error: ${{ matrix.experimental }} - strategy: - matrix: - platform: [ "macos", "ubuntu", "windows" ] - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] - include: - - experimental: false - - # - python-version: "3.13-dev" - # experimental: true - steps: - - uses: actions/checkout@v4.1.1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.7.1 - with: - python-version: ${{ matrix.python-version }} - - name: Install dev dependencies - run: | - # We don't actually want to install toga-core; - # we just want the dev extras so we have a known version of tox and coverage - python -m pip install ./core[dev] - - name: Get packages - uses: actions/download-artifact@v3.0.2 - with: - name: ${{ needs.package.outputs.artifact-name }} - - name: Test - run: | - # The $(ls ...) shell expansion is done in the Github environment; - # the value of TOGA_INSTALL_COMMAND will be a literal string, - # without any shell expansions to perform - TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py - mv core/.coverage core/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} - - name: Store coverage data - uses: actions/upload-artifact@v3.1.3 - with: - name: core-coverage-data - path: "core/.coverage.*" - if-no-files-found: error - - core-coverage: - name: Combine & check core coverage. - runs-on: ubuntu-latest - needs: core - steps: - - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4.7.1 - with: - # Use latest, so it understands all syntax. - python-version: ${{ env.max_python_version }} - - name: Install dev dependencies - run: | - # We don't actually want to install toga-core; - # we just want the dev extras so we have a known version of coverage - python -m pip install ./core[dev] - - name: Retrieve coverage data - uses: actions/download-artifact@v3.0.2 - with: - name: core-coverage-data - path: core - - name: Generate coverage report - run: | - cd core - python -m coverage combine - python -m coverage html --skip-covered --skip-empty - python -m coverage report --rcfile ../pyproject.toml --fail-under=100 - - name: Upload HTML report if check failed. - uses: actions/upload-artifact@v3.1.3 - with: - name: html-coverage-report - path: core/htmlcov - if: ${{ failure() }} + # FIXME + # core: + # runs-on: ${{ matrix.platform }}-latest + # needs: [pre-commit, towncrier, package] + # continue-on-error: ${{ matrix.experimental }} + # strategy: + # matrix: + # platform: [ "macos", "ubuntu", "windows" ] + # python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + # include: + # - experimental: false + + # # - python-version: "3.13-dev" + # # experimental: true + # steps: + # - uses: actions/checkout@v4.1.1 + # - name: Set up Python ${{ matrix.python-version }} + # uses: actions/setup-python@v4.7.1 + # with: + # python-version: ${{ matrix.python-version }} + # - name: Install dev dependencies + # run: | + # # We don't actually want to install toga-core; + # # we just want the dev extras so we have a known version of tox and coverage + # python -m pip install ./core[dev] + # - name: Get packages + # uses: actions/download-artifact@v3.0.2 + # with: + # name: ${{ needs.package.outputs.artifact-name }} + # - name: Test + # run: | + # # The $(ls ...) shell expansion is done in the Github environment; + # # the value of TOGA_INSTALL_COMMAND will be a literal string, + # # without any shell expansions to perform + # TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py + # mv core/.coverage core/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} + # - name: Store coverage data + # uses: actions/upload-artifact@v3.1.3 + # with: + # name: core-coverage-data + # path: "core/.coverage.*" + # if-no-files-found: error + + # core-coverage: + # name: Combine & check core coverage. + # runs-on: ubuntu-latest + # needs: core + # steps: + # - uses: actions/checkout@v4.1.1 + # with: + # fetch-depth: 0 + # - uses: actions/setup-python@v4.7.1 + # with: + # # Use latest, so it understands all syntax. + # python-version: ${{ env.max_python_version }} + # - name: Install dev dependencies + # run: | + # # We don't actually want to install toga-core; + # # we just want the dev extras so we have a known version of coverage + # python -m pip install ./core[dev] + # - name: Retrieve coverage data + # uses: actions/download-artifact@v3.0.2 + # with: + # name: core-coverage-data + # path: core + # - name: Generate coverage report + # run: | + # cd core + # python -m coverage combine + # python -m coverage html --skip-covered --skip-empty + # python -m coverage report --rcfile ../pyproject.toml --fail-under=100 + # - name: Upload HTML report if check failed. + # uses: actions/upload-artifact@v3.1.3 + # with: + # name: html-coverage-report + # path: core/htmlcov + # if: ${{ failure() }} testbed: runs-on: ${{ matrix.runs-on }} - needs: core + needs: package # FIXME core strategy: fail-fast: false matrix: diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index b18da030b9..84ddeda159 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -313,8 +313,12 @@ async def test_current_window(app, app_probe, main_window): """The current window can be retrieved.""" try: assert app.current_window == main_window + + # When all windows are hidden, WinForms and Cocoa return None, while GTK + # returns the last active window. main_window.hide() - assert app.current_window is None + assert app.current_window in [None, main_window] + main_window.show() assert app.current_window == main_window finally: diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 8466c5b765..b9ad941ad6 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -91,11 +91,11 @@ class CURSORINFO(ctypes.Structure): if not GetCursorInfo(ctypes.byref(info)): raise RuntimeError("GetCursorInfo failed") - print(f"FIXME {info.flags=}") - # Local testing returns 1 ("the cursor is showing"). The GitHub Actions runner + print(f"FIXME {info.flags=}, {info.hCursor=}") + # `flags` is 0 or 1 in local testing, but the GitHub Actions runner always # returns 2 ("the system is not drawing the cursor because the user is providing # input through touch or pen instead of the mouse"). - return info.flags in (1, 2) + return info.hCursor is not None def is_full_screen(self, window): return WindowProbe(self.app, window).is_full_screen From 40d712039087d5d24cdf838c7ab2b4c2e9399649 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 29 Oct 2023 11:09:08 +0000 Subject: [PATCH 50/66] More CI debugging --- winforms/src/toga_winforms/app.py | 11 +++++++++++ winforms/src/toga_winforms/libs/proactor.py | 6 +++++- winforms/tests_backend/app.py | 3 +-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 994b3686f9..03d113d6be 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -66,12 +66,20 @@ def create(self): # Windows Versioning Check Sources : https://www.lifewire.com/windows-version-numbers-2625171 # and https://docs.microsoft.com/en-us/windows/release-information/ win_version = Environment.OSVersion.Version + + # FIXME + from warnings import warn + + warn(str((win_version.Major, win_version.Minor, win_version.Build))) + if win_version.Major >= 6: # Checks for Windows Vista or later # Represents Windows 8.1 up to Windows 10 before Build 1703 which should use # SetProcessDpiAwareness(True) if (win_version.Major == 6 and win_version.Minor == 3) or ( win_version.Major == 10 and win_version.Build < 15063 ): # pragma: no cover + warn("FIXME 1") + windll.shcore.SetProcessDpiAwareness(True) print( "WARNING: Your Windows version doesn't support DPI-independent rendering. " @@ -80,9 +88,12 @@ def create(self): # Represents Windows 10 Build 1703 and beyond which should use # SetProcessDpiAwarenessContext(-2) elif win_version.Major == 10 and win_version.Build >= 15063: + warn("FIXME 2") + windll.user32.SetProcessDpiAwarenessContext(-2) # Any other version of windows should use SetProcessDPIAware() else: # pragma: no cover + warn("FIXME 3") windll.user32.SetProcessDPIAware() self.native.EnableVisualStyles() diff --git a/winforms/src/toga_winforms/libs/proactor.py b/winforms/src/toga_winforms/libs/proactor.py index 53c59b386a..4cf3a25176 100644 --- a/winforms/src/toga_winforms/libs/proactor.py +++ b/winforms/src/toga_winforms/libs/proactor.py @@ -77,7 +77,11 @@ def enqueue_tick(self): def tick(self, *args, **kwargs): """Cause a single iteration of the event loop to run on the main GUI thread.""" - if not self.app._is_exiting: # pragma: no branch + + # This function doesn't report as covered, probably because it runs on a + # non-Python-created thread (see App.run_app). But it must actually be covered, + # otherwise nothing would work. + if not self.app._is_exiting: # pragma: no cover action = Action(self.run_once_recurring) self.app.app_dispatcher.Invoke(action) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index b9ad941ad6..986fe574f7 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -91,10 +91,9 @@ class CURSORINFO(ctypes.Structure): if not GetCursorInfo(ctypes.byref(info)): raise RuntimeError("GetCursorInfo failed") - print(f"FIXME {info.flags=}, {info.hCursor=}") # `flags` is 0 or 1 in local testing, but the GitHub Actions runner always # returns 2 ("the system is not drawing the cursor because the user is providing - # input through touch or pen instead of the mouse"). + # input through touch or pen instead of the mouse"). hCursor is more reliable. return info.hCursor is not None def is_full_screen(self, window): From 387a4aa4ebe837bbdc8fd967bf3c997447efe202 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 29 Oct 2023 13:00:53 +0000 Subject: [PATCH 51/66] Remove CI debugging --- .github/workflows/ci.yml | 155 +++++++++++++++--------------- winforms/src/toga_winforms/app.py | 11 --- 2 files changed, 77 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b9313975f..77d4a20475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,87 +58,86 @@ jobs: - "web" - "winforms" - # FIXME - # core: - # runs-on: ${{ matrix.platform }}-latest - # needs: [pre-commit, towncrier, package] - # continue-on-error: ${{ matrix.experimental }} - # strategy: - # matrix: - # platform: [ "macos", "ubuntu", "windows" ] - # python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] - # include: - # - experimental: false - - # # - python-version: "3.13-dev" - # # experimental: true - # steps: - # - uses: actions/checkout@v4.1.1 - # - name: Set up Python ${{ matrix.python-version }} - # uses: actions/setup-python@v4.7.1 - # with: - # python-version: ${{ matrix.python-version }} - # - name: Install dev dependencies - # run: | - # # We don't actually want to install toga-core; - # # we just want the dev extras so we have a known version of tox and coverage - # python -m pip install ./core[dev] - # - name: Get packages - # uses: actions/download-artifact@v3.0.2 - # with: - # name: ${{ needs.package.outputs.artifact-name }} - # - name: Test - # run: | - # # The $(ls ...) shell expansion is done in the Github environment; - # # the value of TOGA_INSTALL_COMMAND will be a literal string, - # # without any shell expansions to perform - # TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py - # mv core/.coverage core/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} - # - name: Store coverage data - # uses: actions/upload-artifact@v3.1.3 - # with: - # name: core-coverage-data - # path: "core/.coverage.*" - # if-no-files-found: error - - # core-coverage: - # name: Combine & check core coverage. - # runs-on: ubuntu-latest - # needs: core - # steps: - # - uses: actions/checkout@v4.1.1 - # with: - # fetch-depth: 0 - # - uses: actions/setup-python@v4.7.1 - # with: - # # Use latest, so it understands all syntax. - # python-version: ${{ env.max_python_version }} - # - name: Install dev dependencies - # run: | - # # We don't actually want to install toga-core; - # # we just want the dev extras so we have a known version of coverage - # python -m pip install ./core[dev] - # - name: Retrieve coverage data - # uses: actions/download-artifact@v3.0.2 - # with: - # name: core-coverage-data - # path: core - # - name: Generate coverage report - # run: | - # cd core - # python -m coverage combine - # python -m coverage html --skip-covered --skip-empty - # python -m coverage report --rcfile ../pyproject.toml --fail-under=100 - # - name: Upload HTML report if check failed. - # uses: actions/upload-artifact@v3.1.3 - # with: - # name: html-coverage-report - # path: core/htmlcov - # if: ${{ failure() }} + core: + runs-on: ${{ matrix.platform }}-latest + needs: [pre-commit, towncrier, package] + continue-on-error: ${{ matrix.experimental }} + strategy: + matrix: + platform: [ "macos", "ubuntu", "windows" ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + include: + - experimental: false + + # - python-version: "3.13-dev" + # experimental: true + steps: + - uses: actions/checkout@v4.1.1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4.7.1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dev dependencies + run: | + # We don't actually want to install toga-core; + # we just want the dev extras so we have a known version of tox and coverage + python -m pip install ./core[dev] + - name: Get packages + uses: actions/download-artifact@v3.0.2 + with: + name: ${{ needs.package.outputs.artifact-name }} + - name: Test + run: | + # The $(ls ...) shell expansion is done in the Github environment; + # the value of TOGA_INSTALL_COMMAND will be a literal string, + # without any shell expansions to perform + TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py + mv core/.coverage core/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} + - name: Store coverage data + uses: actions/upload-artifact@v3.1.3 + with: + name: core-coverage-data + path: "core/.coverage.*" + if-no-files-found: error + + core-coverage: + name: Combine & check core coverage. + runs-on: ubuntu-latest + needs: core + steps: + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4.7.1 + with: + # Use latest, so it understands all syntax. + python-version: ${{ env.max_python_version }} + - name: Install dev dependencies + run: | + # We don't actually want to install toga-core; + # we just want the dev extras so we have a known version of coverage + python -m pip install ./core[dev] + - name: Retrieve coverage data + uses: actions/download-artifact@v3.0.2 + with: + name: core-coverage-data + path: core + - name: Generate coverage report + run: | + cd core + python -m coverage combine + python -m coverage html --skip-covered --skip-empty + python -m coverage report --rcfile ../pyproject.toml --fail-under=100 + - name: Upload HTML report if check failed. + uses: actions/upload-artifact@v3.1.3 + with: + name: html-coverage-report + path: core/htmlcov + if: ${{ failure() }} testbed: runs-on: ${{ matrix.runs-on }} - needs: package # FIXME core + needs: core strategy: fail-fast: false matrix: diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 03d113d6be..994b3686f9 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -66,20 +66,12 @@ def create(self): # Windows Versioning Check Sources : https://www.lifewire.com/windows-version-numbers-2625171 # and https://docs.microsoft.com/en-us/windows/release-information/ win_version = Environment.OSVersion.Version - - # FIXME - from warnings import warn - - warn(str((win_version.Major, win_version.Minor, win_version.Build))) - if win_version.Major >= 6: # Checks for Windows Vista or later # Represents Windows 8.1 up to Windows 10 before Build 1703 which should use # SetProcessDpiAwareness(True) if (win_version.Major == 6 and win_version.Minor == 3) or ( win_version.Major == 10 and win_version.Build < 15063 ): # pragma: no cover - warn("FIXME 1") - windll.shcore.SetProcessDpiAwareness(True) print( "WARNING: Your Windows version doesn't support DPI-independent rendering. " @@ -88,12 +80,9 @@ def create(self): # Represents Windows 10 Build 1703 and beyond which should use # SetProcessDpiAwarenessContext(-2) elif win_version.Major == 10 and win_version.Build >= 15063: - warn("FIXME 2") - windll.user32.SetProcessDpiAwarenessContext(-2) # Any other version of windows should use SetProcessDPIAware() else: # pragma: no cover - warn("FIXME 3") windll.user32.SetProcessDPIAware() self.native.EnableVisualStyles() From f063b37ad29b76e82fee648b3136a71fb7144f29 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 29 Oct 2023 17:32:21 +0000 Subject: [PATCH 52/66] examples/command working correctly on Android --- android/src/toga_android/app.py | 69 +++++++++++------------------ android/src/toga_android/command.py | 5 ++- android/src/toga_android/window.py | 2 +- 3 files changed, 30 insertions(+), 46 deletions(-) diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index aaa97187da..7a0a150b15 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -6,7 +6,6 @@ from java import dynamic_proxy from org.beeware.android import IPythonApp, MainActivity -import toga from toga.command import GROUP_BREAK, SECTION_BREAK, Group from .libs import events @@ -78,15 +77,13 @@ def onConfigurationChanged(self, new_config): pass def onOptionsItemSelected(self, menuitem): - consumed = False - try: - cmd = self.menuitem_mapping[menuitem.getItemId()] - consumed = True - if cmd.action is not None: - cmd.action(menuitem) - except KeyError: - print("menu item id not found in menuitem_mapping dictionary!") - return consumed + itemid = menuitem.getItemId() + if itemid == Menu.NONE: + # This method also fires when opening submenus + return False + else: + self.menuitem_mapping[itemid].action(None) + return True def onPrepareOptionsMenu(self, menu): menu.clear() @@ -98,46 +95,34 @@ def onPrepareOptionsMenu(self, menu): for cmd in self._impl.interface.commands: if cmd == SECTION_BREAK or cmd == GROUP_BREAK: continue - if cmd in self._impl.interface.main_window.toolbar: - continue # do not show toolbar commands in the option menu (except when overflowing) - grouppath = cmd.group.path - if grouppath[0] != Group.COMMANDS: - # only the Commands group (and its subgroups) are supported - # other groups should eventually go into the navigation drawer - continue if cmd.group.key in menulist: menugroup = menulist[cmd.group.key] else: # create all missing submenus parentmenu = menu - for group in grouppath: - groupkey = group.key + groupkey = () + for section, order, text in cmd.group.key: + groupkey += ((section, order, text),) if groupkey in menulist: menugroup = menulist[groupkey] else: - if group.text == toga.Group.COMMANDS.text: + if len(groupkey) == 1 and text == Group.COMMANDS.text: + # Add this group directly to the top-level menu menulist[groupkey] = menu menugroup = menu else: - itemid += 1 - order = Menu.NONE if group.order is None else group.order - menugroup = parentmenu.addSubMenu( - Menu.NONE, itemid, order, group.text - ) # groupId, itemId, order, title + # Add all other groups as submenus + menugroup = parentmenu.addSubMenu(text) menulist[groupkey] = menugroup parentmenu = menugroup + # create menu item itemid += 1 - order = Menu.NONE if cmd.order is None else cmd.order - menuitem = menugroup.add( - Menu.NONE, itemid, order, cmd.text - ) # groupId, itemId, order, title + menuitem = menugroup.add(Menu.NONE, itemid, Menu.NONE, cmd.text) menuitem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_NEVER) menuitem.setEnabled(cmd.enabled) - self.menuitem_mapping[ - itemid - ] = cmd # store itemid for use in onOptionsItemSelected + self.menuitem_mapping[itemid] = cmd # create toolbar actions if self._impl.interface.main_window: @@ -145,13 +130,10 @@ def onPrepareOptionsMenu(self, menu): if cmd == SECTION_BREAK or cmd == GROUP_BREAK: continue itemid += 1 - order = Menu.NONE if cmd.order is None else cmd.order - menuitem = menu.add( - Menu.NONE, itemid, order, cmd.text - ) # groupId, itemId, order, title - menuitem.setShowAsActionFlags( - MenuItem.SHOW_AS_ACTION_IF_ROOM - ) # toolbar button / item in options menu on overflow + menuitem = menu.add(Menu.NONE, itemid, Menu.NONE, cmd.text) + # SHOW_AS_ACTION_IF_ROOM is too conservative, showing only 2 items on + # a normal-size screen in portrait. + menuitem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) menuitem.setEnabled(cmd.enabled) if cmd.icon: icon = Drawable.createFromPath(str(cmd.icon._impl.path)) @@ -159,10 +141,9 @@ def onPrepareOptionsMenu(self, menu): menuitem.setIcon(icon) else: print("Could not create icon: " + str(cmd.icon._impl.path)) - self.menuitem_mapping[ - itemid - ] = cmd # store itemid for use in onOptionsItemSelected + self.menuitem_mapping[itemid] = cmd + # Display the menu. return True @@ -185,8 +166,8 @@ def create(self): # Call user code to populate the main window self.interface._startup() - def open_document(self, fileURL): - print("Can't open document %s (yet)" % fileURL) + def create_menus(self): + self.native.invalidateOptionsMenu() # Triggers onPrepareOptionsMenu def main_loop(self): # In order to support user asyncio code, start the Python/Android cooperative event loop. diff --git a/android/src/toga_android/command.py b/android/src/toga_android/command.py index 8575d29e27..1cec2474cc 100644 --- a/android/src/toga_android/command.py +++ b/android/src/toga_android/command.py @@ -1,7 +1,10 @@ +from org.beeware.android import MainActivity + + class Command: def __init__(self, interface): self.interface = interface self.native = [] def set_enabled(self, value): - pass + MainActivity.singletonThis.invalidateOptionsMenu() diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 48e9c10cf2..533b5d8577 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -65,7 +65,7 @@ def set_size(self, size): pass def create_toolbar(self): - pass + pass # Handled by onPrepareOptionsMenu in app.py def show(self): pass From 4cde00c051a7ba0b0bffc5222b1e16d580f227bb Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 29 Oct 2023 20:53:01 +0000 Subject: [PATCH 53/66] Android passing all tests --- android/src/toga_android/app.py | 60 +++++++++++++--- android/src/toga_android/window.py | 2 +- android/tests_backend/app.py | 81 +++++++++++++++++++++- android/tests_backend/widgets/timeinput.py | 6 +- android/tests_backend/window.py | 39 +++++++++++ 5 files changed, 172 insertions(+), 16 deletions(-) diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 7a0a150b15..fd86fb6aef 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -1,4 +1,5 @@ import asyncio +import sys from android.graphics.drawable import Drawable from android.media import RingtoneManager @@ -6,7 +7,7 @@ from java import dynamic_proxy from org.beeware.android import IPythonApp, MainActivity -from toga.command import GROUP_BREAK, SECTION_BREAK, Group +from toga.command import GROUP_BREAK, SECTION_BREAK, Command, Group from .libs import events from .window import Window @@ -87,13 +88,15 @@ def onOptionsItemSelected(self, menuitem): def onPrepareOptionsMenu(self, menu): menu.clear() - itemid = 0 + itemid = 1 # 0 is the same as Menu.NONE. + groupid = 1 menulist = {} # dictionary with all menus self.menuitem_mapping.clear() # create option menu for cmd in self._impl.interface.commands: if cmd == SECTION_BREAK or cmd == GROUP_BREAK: + groupid += 1 continue if cmd.group.key in menulist: @@ -113,26 +116,29 @@ def onPrepareOptionsMenu(self, menu): menugroup = menu else: # Add all other groups as submenus - menugroup = parentmenu.addSubMenu(text) + menugroup = parentmenu.addSubMenu( + groupid, Menu.NONE, Menu.NONE, text + ) menulist[groupkey] = menugroup parentmenu = menugroup # create menu item - itemid += 1 - menuitem = menugroup.add(Menu.NONE, itemid, Menu.NONE, cmd.text) + menuitem = menugroup.add(groupid, itemid, Menu.NONE, cmd.text) menuitem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_NEVER) menuitem.setEnabled(cmd.enabled) self.menuitem_mapping[itemid] = cmd + itemid += 1 # create toolbar actions if self._impl.interface.main_window: for cmd in self._impl.interface.main_window.toolbar: if cmd == SECTION_BREAK or cmd == GROUP_BREAK: + groupid += 1 continue - itemid += 1 - menuitem = menu.add(Menu.NONE, itemid, Menu.NONE, cmd.text) + + menuitem = menu.add(groupid, itemid, Menu.NONE, cmd.text) # SHOW_AS_ACTION_IF_ROOM is too conservative, showing only 2 items on - # a normal-size screen in portrait. + # a medium-size screen in portrait. menuitem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) menuitem.setEnabled(cmd.enabled) if cmd.icon: @@ -142,6 +148,7 @@ def onPrepareOptionsMenu(self, menu): else: print("Could not create icon: " + str(cmd.icon._impl.path)) self.menuitem_mapping[itemid] = cmd + itemid += 1 # Display the menu. return True @@ -166,6 +173,15 @@ def create(self): # Call user code to populate the main window self.interface._startup() + self.interface.commands.add( + # About should be the last item in the menu, in a section on its own. + Command( + lambda _: self.interface.about(), + f"About {self.interface.formal_name}", + section=sys.maxsize, + ), + ) + def create_menus(self): self.native.invalidateOptionsMenu() # Triggers onPrepareOptionsMenu @@ -181,7 +197,21 @@ def set_main_window(self, window): pass def show_about_dialog(self): - self.interface.factory.not_implemented("App.show_about_dialog()") + message_parts = [] + if self.interface.version is not None: + message_parts.append( + f"{self.interface.formal_name} v{self.interface.version}" + ) + else: + message_parts.append(self.interface.formal_name) + + if self.interface.author is not None: + message_parts.append(f"Author: {self.interface.author}") + if self.interface.description is not None: + message_parts.append(f"\n{self.interface.description}") + self.interface.main_window.info_dialog( + f"About {self.interface.formal_name}", "\n".join(message_parts) + ) def beep(self): uri = RingtoneManager.getActualDefaultRingtoneUri( @@ -193,6 +223,12 @@ def beep(self): def exit(self): pass + def get_current_window(self): + return self.interface.main_window._impl + + def set_current_window(self, window): + pass + async def intent_result(self, intent): """Calls an Intent and waits for its result. @@ -215,6 +251,12 @@ async def intent_result(self, intent): except AttributeError: raise RuntimeError("No appropriate Activity found to handle this intent.") + def enter_full_screen(self, windows): + pass + + def exit_full_screen(self, windows): + pass + def hide_cursor(self): pass diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 533b5d8577..aec16221be 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -65,7 +65,7 @@ def set_size(self, size): pass def create_toolbar(self): - pass # Handled by onPrepareOptionsMenu in app.py + self.app.native.invalidateOptionsMenu() def show(self): pass diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 30bed800b1..21c144ae7d 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -1,17 +1,24 @@ from pathlib import Path +from android import R from org.beeware.android import MainActivity +from pytest import xfail + +from toga import Group from .probe import BaseProbe +from .window import WindowProbe class AppProbe(BaseProbe): def __init__(self, app): super().__init__(app) - assert isinstance(self.app._impl.native, MainActivity) + self.native = self.app._impl.native + self.main_window_probe = WindowProbe(self.app, app.main_window) + assert isinstance(self.native, MainActivity) def get_app_context(self): - return self.app._impl.native.getApplicationContext() + return self.native.getApplicationContext() @property def config_path(self): @@ -28,3 +35,73 @@ def cache_path(self): @property def logs_path(self): return Path(self.get_app_context().getFilesDir().getPath()) / "log" + + def _menu_item(self, path): + menu = self.main_window_probe._native_menu() + for i_path, label in enumerate(path): + if i_path == 0 and label == Group.COMMANDS.text: + continue + + for i_item in range(menu.size()): + item = menu.getItem(i_item) + assert not item.requestsActionButton() + if item.getTitle() == label and not item.requiresActionButton(): + break + else: + raise AssertionError(f"no item named {path[:i_path+1]}") + + if i_path < len(path) - 1: + menu = item.getSubMenu() + assert menu is not None + + return item + + def _activate_menu_item(self, path): + assert self.native.onOptionsItemSelected(self._menu_item(path)) + + def activate_menu_exit(self): + xfail("This backend doesn't have an exit command") + + def activate_menu_about(self): + self._activate_menu_item(["About Toga Testbed"]) + + async def close_about_dialog(self): + await self.main_window_probe.close_info_dialog(None) + + def activate_menu_visit_homepage(self): + xfail("This backend doesn't have a visit homepage command") + + def assert_menu_item(self, path, *, enabled=True): + assert self._menu_item(path).isEnabled() == enabled + + def assert_system_menus(self): + self.assert_menu_item(["About Toga Testbed"]) + + def activate_menu_close_window(self): + xfail("This backend doesn't have a window management menu") + + def activate_menu_close_all_windows(self): + xfail("This backend doesn't have a window management menu") + + def activate_menu_minimize(self): + xfail("This backend doesn't have a window management menu") + + def keystroke(self, combination): + xfail("This backend doesn't use keyboard shortcuts") + + def enter_background(self): + xfail( + "This is possible (https://stackoverflow.com/a/7071289), but there's no " + "easy way to bring it to the foreground again" + ) + + def enter_foreground(self): + xfail("See enter_background") + + def terminate(self): + xfail("Can't simulate this action without killing the app") + + def rotate(self): + self.native.findViewById( + R.id.content + ).getViewTreeObserver().dispatchOnGlobalLayout() diff --git a/android/tests_backend/widgets/timeinput.py b/android/tests_backend/widgets/timeinput.py index 4c784e480f..0edd7f6044 100644 --- a/android/tests_backend/widgets/timeinput.py +++ b/android/tests_backend/widgets/timeinput.py @@ -1,7 +1,7 @@ import re from datetime import time -from android import R as android_R +from android import R from android.widget import TimePicker from .base import find_view_by_type @@ -34,9 +34,7 @@ async def _change_dialog_value(self, delta): @property def _picker(self): - picker = find_view_by_type( - self._dialog.findViewById(android_R.id.content), TimePicker - ) + picker = find_view_by_type(self._dialog.findViewById(R.id.content), TimePicker) assert picker is not None assert picker.is24HourView() return picker diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index 20a9747e01..bc283f43a3 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -1,4 +1,5 @@ import pytest +from androidx.appcompat import R as appcompat_R from .probe import BaseProbe @@ -6,6 +7,7 @@ class WindowProbe(BaseProbe): def __init__(self, app, window): super().__init__(app) + self.native = self.app._impl.native async def wait_for_window(self, message, minimize=False, full_screen=False): await self.redraw(message) @@ -48,3 +50,40 @@ async def close_open_file_dialog(self, dialog, result, multiple_select): async def close_select_folder_dialog(self, dialog, result, multiple_select): pytest.skip("Select Folder dialog not implemented on Android") + + def _native_menu(self): + return self.native.findViewById(appcompat_R.id.action_bar).getMenu() + + def _toolbar_items(self): + result = [] + prev_group = None + menu = self._native_menu() + for i_item in range(menu.size()): + item = menu.getItem(i_item) + assert not item.requestsActionButton() + + if item.requiresActionButton(): + if prev_group and prev_group != item.getGroupId(): + # The separator doesn't actually appear, but it keeps the indices + # correct for the tests. + result.append(None) + prev_group = item.getGroupId() + result.append(item) + + return result + + def has_toolbar(self): + return bool(self._toolbar_items()) + + def assert_is_toolbar_separator(self, index, section=False): + assert self._toolbar_items()[index] is None + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self._toolbar_items()[index] + assert item.getTitle() == label + # Tooltips are not implemented + assert (item.getIcon() is not None) == has_icon + assert item.isEnabled() == enabled + + def press_toolbar_button(self, index): + self.native.onOptionsItemSelected(self._toolbar_items()[index]) From 2e69615f0bc25f95a34ca6a7cbebf55e152b0aba Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 29 Oct 2023 21:15:59 +0000 Subject: [PATCH 54/66] Android app.py at 100% coverage --- android/src/toga_android/app.py | 32 +++++++++++++++++--------------- android/tests_backend/app.py | 2 ++ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index fd86fb6aef..9bb6032a64 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -1,7 +1,7 @@ import asyncio import sys -from android.graphics.drawable import Drawable +from android.graphics.drawable import BitmapDrawable from android.media import RingtoneManager from android.view import Menu, MenuItem from java import dynamic_proxy @@ -41,18 +41,19 @@ def onResume(self): print("Toga app: onResume") def onPause(self): - print("Toga app: onPause") + print("Toga app: onPause") # pragma: no cover def onStop(self): - print("Toga app: onStop") + print("Toga app: onStop") # pragma: no cover def onDestroy(self): - print("Toga app: onDestroy") + print("Toga app: onDestroy") # pragma: no cover def onRestart(self): - print("Toga app: onRestart") + print("Toga app: onRestart") # pragma: no cover - def onActivityResult(self, requestCode, resultCode, resultData): + # TODO #1798: document and test this somehow + def onActivityResult(self, requestCode, resultCode, resultData): # pragma: no cover """Callback method, called from MainActivity when an Intent ends. :param int requestCode: The integer request code originally supplied to startActivityForResult(), @@ -75,7 +76,7 @@ def onActivityResult(self, requestCode, resultCode, resultData): print("No intent matching request code {requestCode}") def onConfigurationChanged(self, new_config): - pass + pass # pragma: no cover def onOptionsItemSelected(self, menuitem): itemid = menuitem.getItemId() @@ -130,7 +131,7 @@ def onPrepareOptionsMenu(self, menu): itemid += 1 # create toolbar actions - if self._impl.interface.main_window: + if self._impl.interface.main_window: # pragma: no branch for cmd in self._impl.interface.main_window.toolbar: if cmd == SECTION_BREAK or cmd == GROUP_BREAK: groupid += 1 @@ -142,11 +143,11 @@ def onPrepareOptionsMenu(self, menu): menuitem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) menuitem.setEnabled(cmd.enabled) if cmd.icon: - icon = Drawable.createFromPath(str(cmd.icon._impl.path)) - if icon: - menuitem.setIcon(icon) - else: - print("Could not create icon: " + str(cmd.icon._impl.path)) + menuitem.setIcon( + BitmapDrawable( + self.native.getResources(), cmd.icon._impl.native + ) + ) self.menuitem_mapping[itemid] = cmd itemid += 1 @@ -221,7 +222,7 @@ def beep(self): ringtone.play() def exit(self): - pass + pass # pragma: no cover def get_current_window(self): return self.interface.main_window._impl @@ -229,7 +230,8 @@ def get_current_window(self): def set_current_window(self, window): pass - async def intent_result(self, intent): + # TODO #1798: document and test this somehow + async def intent_result(self, intent): # pragma: no cover """Calls an Intent and waits for its result. A RuntimeError will be raised when the Intent cannot be invoked. diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 21c144ae7d..436b114d29 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -51,6 +51,8 @@ def _menu_item(self, path): raise AssertionError(f"no item named {path[:i_path+1]}") if i_path < len(path) - 1: + # Simulate opening the submenu. + assert self.native.onOptionsItemSelected(item) is False menu = item.getSubMenu() assert menu is not None From 158354510c0232c04c5ad2904ed40b8f5d10ae7c Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 29 Oct 2023 21:52:53 +0000 Subject: [PATCH 55/66] All Android files at 100% coverage --- android/src/toga_android/colors.py | 4 +- android/src/toga_android/keys.py | 8 ++-- android/src/toga_android/libs/events.py | 40 ++++++++++--------- android/src/toga_android/widgets/base.py | 2 +- docs/reference/data/widgets_by_platform.csv | 7 ++-- testbed/tests/widgets/test_scrollcontainer.py | 17 +++++--- 6 files changed, 43 insertions(+), 35 deletions(-) diff --git a/android/src/toga_android/colors.py b/android/src/toga_android/colors.py index aaedc9d77a..ffd8a71e30 100644 --- a/android/src/toga_android/colors.py +++ b/android/src/toga_android/colors.py @@ -1,5 +1,5 @@ from android.graphics import Color -from travertino.colors import NAMED_COLOR, TRANSPARENT +from travertino.colors import TRANSPARENT CACHE = {TRANSPARENT: Color.TRANSPARENT} @@ -8,8 +8,6 @@ def native_color(c): try: color = CACHE[c] except KeyError: - if isinstance(c, str): - c = NAMED_COLOR[c] color = Color.argb( int(c.rgba.a * 255), int(c.rgba.r), int(c.rgba.g), int(c.rgba.b) ) diff --git a/android/src/toga_android/keys.py b/android/src/toga_android/keys.py index 410b758559..59e8cad444 100644 --- a/android/src/toga_android/keys.py +++ b/android/src/toga_android/keys.py @@ -198,13 +198,13 @@ def toga_key(event): # TODO: Confirm the mapping of Control, Meta and Hyper are correct. if event.isCapsLockOn(): - modifiers.add(Key.CAPSLOCK) + modifiers.add(Key.CAPSLOCK) # pragma: no cover if event.isShiftPressed(): - modifiers.add(Key.SHIFT) + modifiers.add(Key.SHIFT) # pragma: no cover if event.isCtrlPressed(): - modifiers.add(Key.MOD_1) + modifiers.add(Key.MOD_1) # pragma: no cover if event.isAltPressed(): - modifiers.add(Key.MOD_2) + modifiers.add(Key.MOD_2) # pragma: no cover return {"key": key, "modifiers": modifiers} except KeyError: # pragma: nocover diff --git a/android/src/toga_android/libs/events.py b/android/src/toga_android/libs/events.py index 0a225b14e1..77cdde3bc2 100644 --- a/android/src/toga_android/libs/events.py +++ b/android/src/toga_android/libs/events.py @@ -53,7 +53,7 @@ def __init__(self): # `executor` thread that typically exists in event loops. The event loop itself relies # on `run_in_executor()` for DNS lookups. In the future, we can restore `run_in_executor()`. async def run_in_executor(self, executor, func, *args): - return func(*args) + return func(*args) # pragma: no cover # Override parent `_call_soon()` to ensure Android wakes us up to do the delayed task. def _call_soon(self, callback, args, context): @@ -73,9 +73,13 @@ def run_forever_cooperatively(self): event loop interop is not paid by apps that don't use the event loop.""" # Based on `BaseEventLoop.run_forever()` in CPython. if self.is_running(): - raise RuntimeError("Refusing to start since loop is already running.") + raise RuntimeError( + "Refusing to start since loop is already running." + ) # pragma: no cover if self._closed: - raise RuntimeError("Event loop is closed. Create a new object.") + raise RuntimeError( + "Event loop is closed. Create a new object." + ) # pragma: no cover self._set_coroutine_origin_tracking(self._debug) self._thread_id = threading.get_ident() @@ -96,7 +100,7 @@ def enqueue_android_wakeup_for_delayed_tasks(self): loop. """ # If we are supposed to stop, actually stop. - if self._stopping: + if self._stopping: # pragma: no cover self._stopping = False self._thread_id = None asyncio.events._set_running_loop(None) @@ -123,7 +127,7 @@ def enqueue_android_wakeup_for_delayed_tasks(self): def _set_coroutine_origin_tracking(self, debug): # If running on Python 3.7 or 3.8, integrate with upstream event loop's debug feature, allowing # unawaited coroutines to have some useful info logged. See https://bugs.python.org/issue32591 - if hasattr(super(), "_set_coroutine_origin_tracking"): + if hasattr(super(), "_set_coroutine_origin_tracking"): # pragma: no cover super()._set_coroutine_origin_tracking(debug) def _get_next_delayed_task_wakeup(self): @@ -139,7 +143,7 @@ def _get_next_delayed_task_wakeup(self): sched_count > _MIN_SCHEDULED_TIMER_HANDLES and self._timer_cancelled_count / sched_count > _MIN_CANCELLED_TIMER_HANDLES_FRACTION - ): + ): # pragma: no cover # Remove delayed calls that were cancelled if their number # is too high new_scheduled = [] @@ -161,7 +165,7 @@ def _get_next_delayed_task_wakeup(self): timeout = None if self._ready or self._stopping: - if self._debug: + if self._debug: # pragma: no cover print("AndroidEventLoop: self.ready is", self._ready) timeout = 0 elif self._scheduled: @@ -198,8 +202,8 @@ def run_delayed_tasks(self): for i in range(ntodo): handle = self._ready.popleft() if handle._cancelled: - continue - if self._debug: + continue # pragma: no cover + if self._debug: # pragma: no cover try: self._current_handle = handle t0 = self.time() @@ -292,7 +296,7 @@ def message_queue(self): # unregister() and register(), so we rely on that as well. def register(self, fileobj, events, data=None): - if self._debug: + if self._debug: # pragma: no cover print( "register() fileobj={fileobj} events={events} data={data}".format( fileobj=fileobj, events=events, data=data @@ -302,7 +306,7 @@ def register(self, fileobj, events, data=None): self.register_with_android(fileobj, events) return ret - def unregister(self, fileobj): + def unregister(self, fileobj): # pragma: no cover self.message_queue.removeOnFileDescriptorEventListener(_create_java_fd(fileobj)) return super().unregister(fileobj) @@ -310,13 +314,13 @@ def reregister_with_android_soon(self, fileobj): def _reregister(): # If the fileobj got unregistered, exit early. key = self._key_from_fd(fileobj) - if key is None: + if key is None: # pragma: no cover if self._debug: print( "reregister_with_android_soon reregister_temporarily_ignored_fd exiting early; key=None" ) return - if self._debug: + if self._debug: # pragma: no cover print( "reregister_with_android_soon reregistering key={key}".format( key=key @@ -328,7 +332,7 @@ def _reregister(): self.loop.call_later(0, _reregister) def register_with_android(self, fileobj, events): - if self._debug: + if self._debug: # pragma: no cover print( "register_with_android() fileobj={fileobj} events={events}".format( fileobj=fileobj, events=events @@ -347,7 +351,7 @@ def handle_fd_wakeup(self, fd, events): Filter the events to just those that are registered, then notify the loop.""" key = self._key_from_fd(fd) - if key is None: + if key is None: # pragma: no cover print( "Warning: handle_fd_wakeup: wakeup for unregistered fd={fd}".format( fd=fd @@ -360,7 +364,7 @@ def handle_fd_wakeup(self, fd, events): if events & event_type and key.events & event_type: key_event_pairs.append((key, event_type)) if key_event_pairs: - if self._debug: + if self._debug: # pragma: no cover print( "handle_fd_wakeup() calling parent for key_event_pairs={key_event_pairs}".format( key_event_pairs=key_event_pairs @@ -368,7 +372,7 @@ def handle_fd_wakeup(self, fd, events): ) # Call superclass private method to notify. self.loop._process_events(key_event_pairs) - else: + else: # pragma: no cover print( "Warning: handle_fd_wakeup(): unnecessary wakeup fd={fd} events={events} key={key}".format( fd=fd, events=events, key=key @@ -406,7 +410,7 @@ def onFileDescriptorEvents(self, fd_obj, events): selectors.EVENT_WRITE have the same value (2).""" # Call hidden (non-private) method to get the numeric FD, so we can pass that to Python. fd = getattr(fd_obj, "getInt$")() - if self._debug: + if self._debug: # pragma: no cover print( "onFileDescriptorEvents woke up for fd={fd} events={events}".format( fd=fd, events=events diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index ea766b2dec..eb080fe21e 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -33,7 +33,7 @@ def scale_out(self, value, rounding=SCALE_DEFAULT_ROUNDING): return self.scale_round(value / self.dpi_scale, rounding) def scale_round(self, value, rounding): - if rounding is None: + if rounding is None: # pragma: no cover return value return int(Decimal(value).to_integral(rounding)) diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 4e19c0e465..ad0f0a8327 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -1,6 +1,6 @@ Component,Type,Component,Description,macOS,GTK,Windows,iOS,Android,Web,Terminal -Application,Core Component,:class:`~toga.App`,The application itself,|b|,|b|,|b|,|b|,|b|,|b|,|b| -DocumentApp,Core Component,:class:`~toga.DocumentApp`,An application that manages documents.,|b|,|b|,|b|,|b|,|b|,|b|, +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|,|y|,|y|,|b|,|b| MainWindow,Core Component,:class:`~toga.MainWindow`,The main window of the application.,|y|,|y|,|y|,|y|,|y|,|b|,|b| ActivityIndicator,General Widget,:class:`~toga.ActivityIndicator`,A spinning activity animation,|y|,|y|,,,,|b|, @@ -30,7 +30,6 @@ SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divi OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,,,, App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|b| Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,, -Command,Resource,:class:`~toga.Command`,Command,|b|,|b|,|b|,,|b|,, -Group,Resource,:class:`~toga.Group`,Command group,|b|,|b|,|b|,|b|,|b|,, +Command,Resource,:class:`~toga.Command`,Command,|y|,|y|,|y|,,|y|,, Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily identifiable visual context to a widget.",|y|,|y|,|y|,|y|,|y|,,|b| Image,Resource,:class:`~toga.Image`,An image,|y|,|y|,|y|,|y|,|y|,, diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 635ee25065..8c0436dd0c 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -475,16 +475,23 @@ async def test_manual_scroll(widget, probe, content, on_scroll): async def test_no_content(widget, probe, content): "The content of the scroll container can be cleared" + original_width = probe.width + assert original_width > 100 widget.content = None await probe.redraw("Content of the scroll container has been cleared") + assert probe.width == original_width - # Force a refresh to see the impact of a set_bounds() when there's + # Force a resize to see the impact of a set_bounds() when there's # no inner content. - widget.refresh() - await probe.redraw("Scroll container layout has been refreshed") + widget.parent.add(other := toga.Box(style=Pack(flex=1))) + await probe.redraw("Scroll container size has been reduced") + reduced_width = probe.width + assert reduced_width == approx(original_width / 2, abs=1) widget.content = content await probe.redraw("Content of the scroll container has been restored") + assert probe.width == reduced_width - widget.refresh() - await probe.redraw("Scroll container layout has been refreshed") + widget.parent.remove(other) + await probe.redraw("Scroll container size has been restored") + assert probe.width == original_width From ff64a99fbdb8af5035b70068cfefa2f0711a567f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 30 Oct 2023 10:00:24 +0800 Subject: [PATCH 56/66] Protect against an edge case of garbage collection. --- cocoa/src/toga_cocoa/constraints.py | 13 +++++++++---- cocoa/src/toga_cocoa/container.py | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index 352ac0c838..b0de131005 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -34,10 +34,15 @@ def __del__(self): # pragma: nocover def _remove_constraints(self): if self.container: # print(f"Remove constraints for {self.widget} in {self.container}") - self.container.native.removeConstraint(self.width_constraint) - self.container.native.removeConstraint(self.height_constraint) - self.container.native.removeConstraint(self.left_constraint) - self.container.native.removeConstraint(self.top_constraint) + # Due to the unpredictability of garbage collection, it's possible for + # the native object of the window's container to be deleted on the ObjC + # side before the constraints for the window have been removed. Protect + # against this possibility. + if self.container.native: + self.container.native.removeConstraint(self.width_constraint) + self.container.native.removeConstraint(self.height_constraint) + self.container.native.removeConstraint(self.left_constraint) + self.container.native.removeConstraint(self.top_constraint) self.width_constraint.release() self.height_constraint.release() diff --git a/cocoa/src/toga_cocoa/container.py b/cocoa/src/toga_cocoa/container.py index 383abee054..f612b291ac 100644 --- a/cocoa/src/toga_cocoa/container.py +++ b/cocoa/src/toga_cocoa/container.py @@ -84,6 +84,7 @@ def __init__( def __del__(self): self._min_height_constraint.release() self._min_width_constraint.release() + self.native = None @property def content(self): From dd9babfd12020751d5507d42fa8d76fdb7257178 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 30 Oct 2023 16:45:00 +0000 Subject: [PATCH 57/66] Restore WindowSet `__isub__` and `__iadd__` methods --- core/src/toga/app.py | 35 ++++++++++++++++++++++++++++++++ core/tests/app/test_windowset.py | 22 ++++++++++++++++++++ examples/dialogs/dialogs/app.py | 2 -- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 2aa4b2371e..531a06f82a 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -91,6 +91,27 @@ def discard(self, window: Window) -> None: raise ValueError(f"{window!r} is not part of this app") self.elements.remove(window) + ###################################################################### + # 2023-10: Backwards compatibility + ###################################################################### + + def __iadd__(self, window: Window) -> None: + # The standard set type does not have a += operator. + warn("Instead of +=, use add()", DeprecationWarning, stacklevel=2) + self.add(window) + return self + + def __isub__(self, other: Window) -> None: + # The standard set type does have a -= operator, but it takes sets rather than + # individual items. + warn("Instead of -=, use discard()", DeprecationWarning, stacklevel=2) + self.discard(other) + return self + + ###################################################################### + # End backwards compatibility + ###################################################################### + def __iter__(self) -> Iterator: return iter(self.elements) @@ -517,6 +538,20 @@ def windows(self) -> Collection[Window]: when they are created, and removed when they are closed.""" return self._windows + ###################################################################### + # 2023-10: Backwards compatibility + ###################################################################### + + # Support WindowSet __iadd__ and __isub__ + @windows.setter + def windows(self, windows): + if windows is not self._windows: + raise AttributeError("can't set attribute") + + ###################################################################### + # End backwards compatibility + ###################################################################### + @property def commands(self) -> MutableSet[Command]: """The commands available in the app.""" diff --git a/core/tests/app/test_windowset.py b/core/tests/app/test_windowset.py index 4ef71c0184..11fffe6770 100644 --- a/core/tests/app/test_windowset.py +++ b/core/tests/app/test_windowset.py @@ -58,3 +58,25 @@ def test_add_discard(app, window1, window2): match=r"Can only discard objects of type toga.Window", ): app.windows.discard(object()) + + +def test_iadd_isub(app, window1, window2): + """The deprecated += and -= operators are redirected to add() and discard()""" + # The windowset has 3 windows - the main window, plus 2 extras + assert window2 in app.windows + assert len(app.windows) == 3 + + with pytest.warns(DeprecationWarning, match=r"Instead of \+=, use add\(\)"): + app.windows += window2 + + assert window2 in app.windows + assert len(app.windows) == 3 + + with pytest.warns(DeprecationWarning, match=r"Instead of -=, use discard\(\)"): + app.windows -= window2 + + assert window2 not in app.windows + assert len(app.windows) == 2 + + with pytest.raises(AttributeError, match=r"can't set attribute"): + app.windows = None diff --git a/examples/dialogs/dialogs/app.py b/examples/dialogs/dialogs/app.py index 8286b064f2..22e37e02d8 100644 --- a/examples/dialogs/dialogs/app.py +++ b/examples/dialogs/dialogs/app.py @@ -181,8 +181,6 @@ async def window_close_handler(self, window): def action_open_secondary_window(self, widget): self.window_counter += 1 window = toga.Window(title=f"New Window {self.window_counter}") - # Both self.windows.add() and self.windows += work: - self.windows += window self.set_window_label_text(len(self.windows) - 1) secondary_label = toga.Label(text="You are in a secondary window!") From de3fff60c8cf4ffccc8594bed8e897c880e58229 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 30 Oct 2023 16:50:12 +0000 Subject: [PATCH 58/66] Revert rename of app_name to distribution_name --- changes/2075.removal.6.rst | 2 +- changes/2075.removal.7.rst | 1 - core/src/toga/app.py | 59 +++++++++++--------------------------- core/tests/app/test_app.py | 37 ++++-------------------- testbed/src/testbed/app.py | 2 +- 5 files changed, 25 insertions(+), 76 deletions(-) delete mode 100644 changes/2075.removal.7.rst diff --git a/changes/2075.removal.6.rst b/changes/2075.removal.6.rst index 718c7c3090..e317cfa4d3 100644 --- a/changes/2075.removal.6.rst +++ b/changes/2075.removal.6.rst @@ -1 +1 @@ -In ``App``, the properties ``id`` and ``name`` have been deprecated in favor of ``app_id`` and ``formal_name`` respectively. +In ``App``, the properties ``id`` and ``name`` have been deprecated in favor of ``app_id`` and ``formal_name`` respectively, and the property ``module_name`` has been removed. diff --git a/changes/2075.removal.7.rst b/changes/2075.removal.7.rst deleted file mode 100644 index cd9454d1ee..0000000000 --- a/changes/2075.removal.7.rst +++ /dev/null @@ -1 +0,0 @@ -In ``App``, the property ``app_name`` has been renamed to ``distribution_name``, and the property ``module_name`` has been removed. diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 531a06f82a..3433234fdc 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -231,7 +231,7 @@ def __init__( self, formal_name: str | None = None, app_id: str | None = None, - distribution_name: str | None = None, + app_name: str | None = None, *, icon: Icon | str | None = None, author: str | None = None, @@ -240,7 +240,6 @@ def __init__( description: str | None = None, startup: AppStartupMethod | None = None, on_exit: OnExitHandler | None = None, - app_name: str | None = None, # DEPRECATED id=None, # DEPRECATED windows=None, # DEPRECATED ): @@ -255,7 +254,7 @@ def __init__( :param app_id: The unique application identifier. This will usually be a reversed domain name, e.g. ``org.beeware.myapp``. If not provided, the metadata key ``App-ID`` must be present. - :param distribution_name: The name of the distribution used to load metadata with + :param app_name: The name of the distribution used to load metadata with :any:`importlib.metadata`. If not provided, the following will be tried in order: @@ -266,8 +265,8 @@ def __init__( distribution name of ``my-app``. #. As a last resort, the name ``toga``. :param icon: The :any:`Icon` for the app. If not provided, Toga will attempt to - load an icon from ``resources/distribution_name``, where - ``distribution_name`` is defined above. If no resource matching this name + load an icon from ``resources/app_name``, where + ``app_name`` is defined above. If no resource matching this name can be found, a warning will be printed, and the app will fall back to a default icon. :param author: The person or organization to be credited as the author of the @@ -280,7 +279,6 @@ 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 app_name: **DEPRECATED** – Renamed to ``distribution_name``. :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 @@ -289,17 +287,6 @@ def __init__( ###################################################################### # 2023-10: Backwards compatibility ###################################################################### - if app_name is not None: - if distribution_name is not None: - raise ValueError("Cannot specify both app_name and distribution_name") - else: - warn( - "App.app_name has been renamed to distribution_name", - DeprecationWarning, - stacklevel=2, - ) - distribution_name = app_name - if id is not None: warn( "App.id is deprecated and will be ignored. Use app_id instead", @@ -323,7 +310,7 @@ def __init__( App.app = self # We need a distribution name to load app metadata. - if distribution_name is None: + if app_name is None: # If the code is contained in appname.py, and you start the app using # `python -m appname`, then __main__.__package__ will be an empty string. # @@ -335,24 +322,24 @@ def __init__( try: main_module_pkg = sys.modules["__main__"].__package__ if main_module_pkg: - distribution_name = main_module_pkg + app_name = main_module_pkg except KeyError: # If there's no __main__ module, we're probably in a test. pass # Try deconstructing the distribution name from the app ID - if (distribution_name is None) and app_id: - distribution_name = app_id.split(".")[-1] + if (app_name is None) and app_id: + app_name = app_id.split(".")[-1] # If we still don't have a distribution name, fall back to ``toga`` as a # last resort. - if distribution_name is None: - distribution_name = "toga" + if app_name is None: + app_name = "toga" # Try to load the app metadata with our best guess of the distribution name. - self._distribution_name = distribution_name + self._app_name = app_name try: - self.metadata = importlib.metadata.metadata(distribution_name) + self.metadata = importlib.metadata.metadata(app_name) except importlib.metadata.PackageNotFoundError: self.metadata = Message() @@ -406,7 +393,7 @@ def __init__( if icon: self.icon = icon else: - self.icon = f"resources/{distribution_name}" + self.icon = f"resources/{app_name}" self.on_exit = on_exit @@ -455,20 +442,10 @@ def formal_name(self) -> str: return self._formal_name @property - def distribution_name(self) -> str: + def app_name(self) -> str: """The name of the distribution used to load metadata with :any:`importlib.metadata` (read-only).""" - return self._distribution_name - - @property - def app_name(self) -> str: - """**DEPRECATED** – Renamed to ``distribution_name``.""" - warn( - "App.app_name has been renamed to distribution_name", - DeprecationWarning, - stacklevel=2, - ) - return self._distribution_name + return self._app_name @property def app_id(self) -> str: @@ -720,7 +697,7 @@ def __init__( self, formal_name: str | None = None, app_id: str | None = None, - distribution_name: str | None = None, + app_name: str | None = None, *, icon: str | None = None, author: str | None = None, @@ -730,7 +707,6 @@ def __init__( startup: AppStartupMethod | None = None, document_types: dict[str, type[Document]] = None, on_exit: OnExitHandler | None = None, - app_name: str | None = None, # DEPRECATED id=None, # DEPRECATED ): """Create a document-based application. @@ -750,7 +726,7 @@ def __init__( super().__init__( formal_name=formal_name, app_id=app_id, - distribution_name=distribution_name, + app_name=app_name, icon=icon, author=author, version=version, @@ -758,7 +734,6 @@ def __init__( description=description, startup=startup, on_exit=on_exit, - app_name=app_name, id=id, ) diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index fac72d68cb..deac2063f5 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -17,7 +17,7 @@ EXPLICIT_FULL_APP_KWARGS = dict( formal_name="Explicit App", app_id="org.beeware.explicit-app", - distribution_name="override-app", + app_name="override-app", ) EXPLICIT_MIN_APP_KWARGS = dict( formal_name="Explicit App", @@ -33,7 +33,7 @@ @pytest.mark.parametrize( ( "kwargs, metadata, main_module, expected_formal_name, expected_app_id, " - "expected_distribution_name" + "expected_app_name" ), [ ########################################################################### @@ -206,7 +206,7 @@ def test_create( main_module, expected_formal_name, expected_app_id, - expected_distribution_name, + expected_app_name, ): """A simple app can be created""" # Monkeypatch the metadata retrieval function @@ -214,9 +214,7 @@ def test_create( metadata_mock = Mock(return_value=metadata) else: metadata_mock = Mock( - side_effect=importlib.metadata.PackageNotFoundError( - expected_distribution_name - ) + side_effect=importlib.metadata.PackageNotFoundError(expected_app_name) ) monkeypatch.setattr(importlib.metadata, "metadata", metadata_mock) @@ -233,10 +231,10 @@ def test_create( assert app.formal_name == expected_formal_name assert app.app_id == expected_app_id - assert app.distribution_name == expected_distribution_name + assert app.app_name == expected_app_name assert app.on_exit._raw is None - metadata_mock.assert_called_once_with(expected_distribution_name) + metadata_mock.assert_called_once_with(expected_app_name) @pytest.mark.parametrize( @@ -602,29 +600,6 @@ async def waiter(): canary.assert_called_once() -def test_deprecated_app_name(): - """The deprecated `app_name` constructor argument and property is redirected to - `distribution_name` - """ - with pytest.raises( - ValueError, match="Cannot specify both app_name and distribution_name" - ): - toga.App( - "Test App", - "org.example.test", - app_name="test_app_name", - distribution_name="test_distribution_name", - ) - - app_name_warning = r"App.app_name has been renamed to distribution_name" - with pytest.warns(DeprecationWarning, match=app_name_warning): - app = toga.App("Test App", "org.example.test", app_name="test_app_name") - - assert app.distribution_name == "test_app_name" - with pytest.warns(DeprecationWarning, match=app_name_warning): - assert app.app_name == "test_app_name" - - def test_deprecated_id(): """The deprecated `id` constructor argument is ignored, and the property of the same name is redirected to `app_id` diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index f4e7d4af36..506c70b5aa 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -90,4 +90,4 @@ def startup(self): def main(): - return Testbed(distribution_name="testbed") + return Testbed(app_name="testbed") From 3c2fa56148ac48eeed49322cc14cd3f4a5195c29 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 30 Oct 2023 17:23:30 +0000 Subject: [PATCH 59/66] Fix link between Window.toolbar and App.commands --- android/src/toga_android/app.py | 4 ++++ core/src/toga/app.py | 7 +++---- core/src/toga/window.py | 1 + core/tests/test_window.py | 19 +++++++++++++++++++ docs/reference/api/resources/command.rst | 5 +++++ examples/command/command/app.py | 2 +- 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 9bb6032a64..bb8ec1b68b 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -100,6 +100,10 @@ def onPrepareOptionsMenu(self, menu): groupid += 1 continue + # Toolbar commands are added below. + if cmd in self._impl.interface.main_window.toolbar: + continue + if cmd.group.key in menulist: menugroup = menulist[cmd.group.key] else: diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 3433234fdc..0fa7677fe5 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -265,10 +265,9 @@ def __init__( distribution name of ``my-app``. #. As a last resort, the name ``toga``. :param icon: The :any:`Icon` for the app. If not provided, Toga will attempt to - load an icon from ``resources/app_name``, where - ``app_name`` is defined above. If no resource matching this name - can be found, a warning will be printed, and the app will fall back to a - default icon. + load an icon from ``resources/app_name``, where ``app_name`` is defined + above. If no resource matching this name can be found, a warning will be + printed, and the app will fall back to a default icon. :param author: The person or organization to be credited as the author of the app. If not provided, the metadata key ``Author`` will be used. :param version: The version number of the app. If not provided, the metadata diff --git a/core/src/toga/window.py b/core/src/toga/window.py index d09d3af1fa..0587bb1e30 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -137,6 +137,7 @@ def __init__( App.app.windows.add(self) self._toolbar = CommandSet(on_change=self._impl.create_toolbar) + self._toolbar.app = self.app self.on_close = on_close @property diff --git a/core/tests/test_window.py b/core/tests/test_window.py index 82c9812b44..7465327f7a 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -115,6 +115,25 @@ def test_title(window, value, expected): assert window.title == expected +def test_toolbar(window, app): + cmd1 = toga.Command(None, "Command 1") + cmd2 = toga.Command(None, "Command 2") + + toolbar = window.toolbar + assert set(toolbar) == set() + assert set(app.commands) == set() + + # Adding a command to the toolbar automatically adds it to the app + toolbar.add(cmd1) + assert set(toolbar) == {cmd1} + assert set(app.commands) == {cmd1} + + # But not vice versa + app.commands.add(cmd2) + assert set(toolbar) == {cmd1} + assert set(app.commands) == {cmd1, cmd2} + + def test_change_content(window, app): """The content of a window can be changed""" assert window.content is None diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index b5abfa53d0..ca4fb73d1f 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -68,6 +68,11 @@ a similar pattern. It doesn't matter what order you add commands to the app - the group, section and order will be used to display the commands in the right order. +If a command is added to a toolbar, it will automatically be added to the app +as well. It isn't possible to have functionality exposed on a toolbar that +isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though +it wasn't explicitly added to the app commands. + Reference --------- diff --git a/examples/command/command/app.py b/examples/command/command/app.py index 327e6ebaff..171fe650c3 100644 --- a/examples/command/command/app.py +++ b/examples/command/command/app.py @@ -136,7 +136,7 @@ def action4(widget): # The order in which commands are added to the app or the toolbar won't # alter anything. Ordering is defined by the command definitions. - self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3, cmd7) + self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3) self.app.main_window.toolbar.add(cmd2, cmd5, cmd7) # Buttons From 3ba85dd827fef5ddd7a05f00a7431b37c8d32fb5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 31 Oct 2023 06:16:47 +0800 Subject: [PATCH 60/66] Document the behavior around implicit command registration. --- examples/command/command/app.py | 2 ++ examples/tutorial2/tutorial/app.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/examples/command/command/app.py b/examples/command/command/app.py index 171fe650c3..52befbe4f8 100644 --- a/examples/command/command/app.py +++ b/examples/command/command/app.py @@ -136,6 +136,8 @@ def action4(widget): # The order in which commands are added to the app or the toolbar won't # alter anything. Ordering is defined by the command definitions. + # cmd7 is only *explicitly* added to the toolbar, which implies adding + # to the app. self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3) self.app.main_window.toolbar.add(cmd2, cmd5, cmd7) diff --git a/examples/tutorial2/tutorial/app.py b/examples/tutorial2/tutorial/app.py index f78cd285bf..e2204eb6b6 100644 --- a/examples/tutorial2/tutorial/app.py +++ b/examples/tutorial2/tutorial/app.py @@ -145,6 +145,8 @@ def action4(widget): self.commands.add(cmd1, cmd0, cmd6, cmd4, cmd5, cmd3) self.main_window = toga.MainWindow(title=self.name) + # Command 2 has not been *explicitly* added to the app. Adding it to + # a toolbar implicitly adds it to the app. self.main_window.toolbar.add(cmd1, cmd3, cmd2, cmd4) self.main_window.content = split From 7f0b952c1684c89a304b6a270d8f1813b3cbdeb4 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 31 Oct 2023 06:26:08 +0800 Subject: [PATCH 61/66] Make windowset +=/-= operations no-ops. --- core/src/toga/app.py | 16 +++++++++++----- core/tests/app/test_windowset.py | 19 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 0fa7677fe5..8c3ade4e63 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -97,15 +97,21 @@ def discard(self, window: Window) -> None: def __iadd__(self, window: Window) -> None: # The standard set type does not have a += operator. - warn("Instead of +=, use add()", DeprecationWarning, stacklevel=2) - self.add(window) + warn( + "Windows are automatically associated with the app; += is not required", + DeprecationWarning, + stacklevel=2, + ) return self def __isub__(self, other: Window) -> None: # The standard set type does have a -= operator, but it takes sets rather than # individual items. - warn("Instead of -=, use discard()", DeprecationWarning, stacklevel=2) - self.discard(other) + warn( + "Windows are automatically removed from the app; -= is not required", + DeprecationWarning, + stacklevel=2, + ) return self ###################################################################### @@ -522,7 +528,7 @@ def windows(self) -> Collection[Window]: @windows.setter def windows(self, windows): if windows is not self._windows: - raise AttributeError("can't set attribute") + raise AttributeError("can't set attribute 'windows'") ###################################################################### # End backwards compatibility diff --git a/core/tests/app/test_windowset.py b/core/tests/app/test_windowset.py index 11fffe6770..fe0e771cd6 100644 --- a/core/tests/app/test_windowset.py +++ b/core/tests/app/test_windowset.py @@ -61,22 +61,29 @@ def test_add_discard(app, window1, window2): def test_iadd_isub(app, window1, window2): - """The deprecated += and -= operators are redirected to add() and discard()""" + """The deprecated += and -= operators are no-ops""" # The windowset has 3 windows - the main window, plus 2 extras assert window2 in app.windows assert len(app.windows) == 3 - with pytest.warns(DeprecationWarning, match=r"Instead of \+=, use add\(\)"): + with pytest.warns( + DeprecationWarning, + match=r"Windows are automatically associated with the app; \+= is not required", + ): app.windows += window2 assert window2 in app.windows assert len(app.windows) == 3 - with pytest.warns(DeprecationWarning, match=r"Instead of -=, use discard\(\)"): + with pytest.warns( + DeprecationWarning, + match=r"Windows are automatically removed from the app; -= is not required", + ): app.windows -= window2 - assert window2 not in app.windows - assert len(app.windows) == 2 + # -= is a no-op. + assert window2 in app.windows + assert len(app.windows) == 3 - with pytest.raises(AttributeError, match=r"can't set attribute"): + with pytest.raises(AttributeError, match=r"can't set attribute 'windows'"): app.windows = None From 2b05ca2cfb12a22274ef13d2f6e9fa03d744484b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 31 Oct 2023 07:01:32 +0800 Subject: [PATCH 62/66] Make the app a required argument to commandset if it's going to be used. --- core/src/toga/command.py | 15 +++++----- core/src/toga/window.py | 6 ++-- core/tests/command/test_commandset.py | 43 ++++++++++++++++++++++++--- core/tests/test_window.py | 3 +- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 6845865aeb..8fa139a47c 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -293,7 +293,11 @@ def __call__(self) -> None: class CommandSet: - def __init__(self, on_change: CommandSetChangeHandler = None): + def __init__( + self, + on_change: CommandSetChangeHandler = None, + app: App | None = None, + ): """ A collection of commands. @@ -306,8 +310,10 @@ def __init__(self, on_change: CommandSetChangeHandler = None): managed by the group. :param on_change: A method that should be invoked when this command set changes. + :param app: The app this command set is associated with, if it is not the app's + own commandset. """ - self._app = None + self._app = app self._commands = set() self.on_change = on_change @@ -327,11 +333,6 @@ def clear(self): def app(self) -> App: return self._app - @app.setter - def app(self, value: App): - self._app = value - self._app.commands.add(*self._commands) - def __len__(self) -> int: return len(self._commands) diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 0587bb1e30..b35431fc9c 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -131,13 +131,15 @@ def __init__( size=size, ) + # Add the window to the app self._app = None if App.app is None: raise RuntimeError("Cannot create a Window before creating an App") App.app.windows.add(self) - self._toolbar = CommandSet(on_change=self._impl.create_toolbar) - self._toolbar.app = self.app + # 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 @property diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py index c4424fc7b1..cfd2fa2970 100644 --- a/core/tests/command/test_commandset.py +++ b/core/tests/command/test_commandset.py @@ -47,13 +47,48 @@ def test_add_clear(app, change_handler): # Command set has commands, and the order is the opposite to the insertion order. assert list(cs) == [cmd1b, cmd1a] - # New Commands aren't known to the app yet + # New Commands aren't known to the app assert list(app.commands) == [cmd_a, cmd_b] - # Assign the commandset to the app - cs.app = app + # Clear the command set + cs.clear() + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set no commands. + assert list(cs) == [] + + # App command set hasn't changed. + assert list(app.commands) == [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 + 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] + + # Create a command set that is linked to the app and add some commands + cs = CommandSet(on_change=change_handler, app=app) + cmd1a = toga.Command(None, text="Test command 1a", order=3) + cmd1b = toga.Command(None, text="Test command 1b", order=1) + cs.add(cmd1a, cmd1b) + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set has commands, and the order is the opposite to the insertion order. + assert list(cs) == [cmd1b, cmd1a] - # Commands are now known to the app + # New Commands are known to the app assert list(app.commands) == [cmd_a, cmd1b, cmd1a, cmd_b] # Add another command to the commandset diff --git a/core/tests/test_window.py b/core/tests/test_window.py index 7465327f7a..fb85632f69 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -115,7 +115,8 @@ def test_title(window, value, expected): assert window.title == expected -def test_toolbar(window, app): +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") From 0df6ea23467c734e006ee6d5e10eb08e731e4a56 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 31 Oct 2023 07:40:55 +0800 Subject: [PATCH 63/66] Ensure stale references to menus are removed on re-creation. --- cocoa/src/toga_cocoa/app.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 82c67d7ea2..31a75e259b 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -322,9 +322,17 @@ def _menu_visit_homepage(self, app, **kwargs): self.interface.visit_homepage() def create_menus(self): - # Recreate the menu + # Recreate the menu. + # 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 = {} + for cmd in self.interface.commands: if cmd == GROUP_BREAK: submenu = None From aaca421a8ba26c35171e1fc536ce8f758563c36c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 31 Oct 2023 07:48:15 +0800 Subject: [PATCH 64/66] An extra scrollcontainer assertion to validate an inconsistent code path. --- testbed/tests/widgets/test_scrollcontainer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 8c0436dd0c..c71ee7e84c 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -85,6 +85,12 @@ async def test_clear_content(widget, probe, small_content): await probe.redraw("Widget content has been re-cleared") assert not probe.has_content + # Apply a style to guarantee a set_bounds() call has been made + # when there is no content. + widget.style.padding = 10 + await probe.redraw("Widget has definitely been refreshed") + assert not probe.has_content + widget.content = small_content await probe.redraw("Widget content has been restored") assert probe.has_content From ce9cc858881d307e9be46842ce2888e1ad40834e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 31 Oct 2023 08:08:37 +0800 Subject: [PATCH 65/66] Ensure Winforms table warnings are captured. --- testbed/tests/widgets/test_table.py | 4 +++- winforms/src/toga_winforms/widgets/table.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 4aefd802e6..10752e90a5 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -487,7 +487,7 @@ async def test_cell_widget(widget, probe): warning_check = contextlib.nullcontext() else: warning_check = pytest.warns( - match=".* does not support the use of widgets in cells" + match=r".* does not support the use of widgets in cells" ) with warning_check: @@ -499,6 +499,8 @@ async def test_cell_widget(widget, probe): probe.assert_cell_content(0, 0, "A0") probe.assert_cell_content(0, 1, "B0") + await probe.redraw("Table row with a widget has been accessed", delay=0.1) + probe.assert_cell_content(1, 0, "A1") probe.assert_cell_content(1, 1, "B1") diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 5b8c1a9b43..c2c8c63214 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -160,7 +160,7 @@ def icon(attr): def text(attr): val = getattr(item, attr, None) if isinstance(val, toga.Widget): - warn("This backend does not support the use of widgets in cells") + warn("Winforms does not support the use of widgets in cells") val = None if isinstance(val, tuple): val = val[1] From 207b4407486b57ae63f16d752438a49639659b95 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 31 Oct 2023 08:39:42 +0000 Subject: [PATCH 66/66] Add a test of adding a command to both toolbar and menus --- core/tests/test_window.py | 17 +++++++++++------ examples/command/command/app.py | 10 ++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/core/tests/test_window.py b/core/tests/test_window.py index fb85632f69..6893887ba6 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -121,18 +121,23 @@ def test_toolbar_implicit_add(window, app): cmd2 = toga.Command(None, "Command 2") toolbar = window.toolbar - assert set(toolbar) == set() - assert set(app.commands) == set() + assert list(toolbar) == [] + assert list(app.commands) == [] # Adding a command to the toolbar automatically adds it to the app toolbar.add(cmd1) - assert set(toolbar) == {cmd1} - assert set(app.commands) == {cmd1} + assert list(toolbar) == [cmd1] + assert list(app.commands) == [cmd1] # But not vice versa app.commands.add(cmd2) - assert set(toolbar) == {cmd1} - assert set(app.commands) == {cmd1, 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): diff --git a/examples/command/command/app.py b/examples/command/command/app.py index 52befbe4f8..6bb3335107 100644 --- a/examples/command/command/app.py +++ b/examples/command/command/app.py @@ -135,10 +135,12 @@ def action4(widget): ) # The order in which commands are added to the app or the toolbar won't - # alter anything. Ordering is defined by the command definitions. - # cmd7 is only *explicitly* added to the toolbar, which implies adding - # to the app. - self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3) + # alter anything. Ordering is determined by the command's properties. + # + # cmd2 and cmd5 are only explicitly added to the toolbar, but that should + # automatically add them to the app. cmd7 is added to both places, but this + # should not cause a duplicate menu item. + self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3, cmd7) self.app.main_window.toolbar.add(cmd2, cmd5, cmd7) # Buttons