diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 307919754..fc429c0eb 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -16,7 +16,6 @@ from collections.abc import ( ValuesView, ) from email.message import Message -from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol from warnings import warn from weakref import WeakValueDictionary @@ -350,9 +349,8 @@ class App: 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 icon: The :any:`icon ` for the app. See the - :attr:`~toga.App.icon` property for details on how values for this property - are handled. + :param icon: The :any:`icon ` for the app. Defaults to + :attr:`toga.Icon.APP_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 @@ -472,7 +470,10 @@ class App: # Instantiate the paths instance for this app. self._paths = Paths() - self.icon = icon + if icon is None: + self.icon = Icon.APP_ICON + else: + self.icon = icon self.on_exit = on_exit @@ -538,39 +539,14 @@ class App: def icon(self) -> Icon: """The Icon for the app. - Can be specified as any valid :any:`icon content `, or :any:`None` - to use a default icon. See the definition of :class:`~toga.Icon` for how - misconfigured and default icons are handled. - - If :any:`None`, Toga will attempt to load an icon from the following sources, in - order: - - * ``resources/``, where ``app_name`` is as defined above - * If the Python interpreter is embedded in an app, the icon of the application - binary - * Otherwise, the Toga logo. + Can be specified as any valid :any:`icon content `. """ return self._icon @icon.setter - def icon(self, icon_or_name: IconContent | None) -> None: + def icon(self, icon_or_name: IconContent) -> None: if isinstance(icon_or_name, Icon): self._icon = icon_or_name - elif icon_or_name is None: - if Path(sys.executable).stem in { - "python", - f"python{sys.version_info.major}", - f"python{sys.version_info.major}.{sys.version_info.minor}", - }: - # We're running as a script, so we can't use the application binary as - # an icon source. Fall back directly to the Toga default icon - default = Icon.DEFAULT_ICON - else: - # Use the application binary's icon as an initial default; if that can't - # be loaded, fall back to Toga icon as a final default. - default = Icon(None, default=Icon.DEFAULT_ICON) - - self._icon = Icon(f"resources/{self.app_name}", default=default) else: self._icon = Icon(icon_or_name) diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index 5f6093061..6e7b771ba 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -39,6 +39,11 @@ class cachedicon: return value +# A sentinel value that is type compatible with the `path` argument, +# but can be used to uniquely identify a request for an application icon +_APP_ICON = "" + + class Icon: @cachedicon def TOGA_ICON(cls) -> Icon: @@ -50,6 +55,20 @@ class Icon: return Icon("toga", system=True) + @cachedicon + def APP_ICON(cls) -> Icon: + """The application icon. + + The application icon will be loaded from ``resources/`` (where ```` is the value of :attr:`toga.App.app_name`). + + If this resource cannot be found, and the app has been packaged as a binary, the + icon from the application binary will be used as a fallback. + + Otherwise, :attr:`~toga.Icon.DEFAULT_ICON` will be used. + """ + return Icon(_APP_ICON) + @cachedicon def DEFAULT_ICON(cls) -> Icon: """The default icon used as a fallback - Toga's "Tiberius the yak" icon.""" @@ -62,73 +81,81 @@ class Icon: def __init__( self, - path: str | Path | None, + path: str | Path, *, system: bool = False, # Deliberately undocumented; for internal use only - default: ( - toga.Icon | None - ) = None, # Deliberately undocumented; for internal use only ): """Create a new icon. :param path: Base filename for the icon. The path can be an absolute file system path, or a path relative to the module that defines your Toga application class. This base filename should *not* contain an extension. If an extension - is specified, it will be ignored. If :any:`None`, the application binary will - be used as the source of the icon. + is specified, it will be ignored. If the icon cannot be found, the default + icon will be :attr:`~toga.Icon.DEFAULT_ICON`. :param system: **For internal use only** - :param default: **For internal use only** """ self.factory = get_platform_factory() try: - if path is None: - # If path is None, load the application binary's icon - self.path = None - self._impl = self.factory.Icon(interface=self, path=None) + # Try to load the icon with the given path snippet. If the request is for the + # app icon, use ``resources/`` as the path. + if path is _APP_ICON: + self.path = Path(f"resources/{toga.App.app.app_name}") else: - # Try to load the icon with the given path snippet self.path = Path(path) - self.system = system - if self.system: - resource_path = Path(self.factory.__file__).parent / "resources" - else: - resource_path = toga.App.app.paths.app - - if self.factory.Icon.SIZES: - full_path = {} - for size in self.factory.Icon.SIZES: - try: - full_path[size] = self._full_path( - size=size, - extensions=self.factory.Icon.EXTENSIONS, - resource_path=resource_path, - ) - except FileNotFoundError: - # This size variant wasn't found; we can skip it - pass - else: - full_path = self._full_path( - size=None, - extensions=self.factory.Icon.EXTENSIONS, - resource_path=resource_path, - ) - - self._impl = self.factory.Icon(interface=self, path=full_path) - except FileNotFoundError: - # If an explicit default has been provided, use it without generating a - # warning. Otherwise, warn about the missing resource. - if default is None: - if self.path: - msg = f"icon {self.path}" - else: - msg = "app icon" - - print(f"WARNING: Can't find {msg}; falling back to default icon") - self._impl = self.DEFAULT_ICON._impl + self.system = system + if self.system: + resource_path = Path(self.factory.__file__).parent / "resources" else: - self._impl = default._impl + resource_path = toga.App.app.paths.app + + if self.factory.Icon.SIZES: + full_path = {} + for size in self.factory.Icon.SIZES: + try: + full_path[size] = self._full_path( + size=size, + extensions=self.factory.Icon.EXTENSIONS, + resource_path=resource_path, + ) + except FileNotFoundError: + # This size variant wasn't found; we can skip it + pass + else: + full_path = self._full_path( + size=None, + extensions=self.factory.Icon.EXTENSIONS, + resource_path=resource_path, + ) + + self._impl = self.factory.Icon(interface=self, path=full_path) + except FileNotFoundError: + # Icon path couldn't be loaded. If the path is the sentinel for the app + # icon, and this isn't running as a script, fall back to the application + # binary + if path is _APP_ICON: + if Path(sys.executable).stem not in { + "python", + f"python{sys.version_info.major}", + f"python{sys.version_info.major}.{sys.version_info.minor}", + }: + try: + # Use the application binary's icon + self._impl = self.factory.Icon(interface=self, path=None) + except FileNotFoundError: + # Can't find the application binary's icon. + print( + "WARNING: Can't find app icon; falling back to default icon" + ) + self._impl = self.DEFAULT_ICON._impl + else: + self._impl = self.DEFAULT_ICON._impl + else: + print( + f"WARNING: Can't find icon {self.path}; falling back to default icon" + ) + self._impl = self.DEFAULT_ICON._impl def _full_path(self, size, extensions, resource_path): platform = toga.platform.current_platform diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 03622d490..e5d8496d4 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -367,25 +367,6 @@ def test_icon(app, construct): assert_action_performed_with(app, "set_icon", icon=toga.Icon("path/to/icon")) -def test_default_icon(app): - """The app icon can be reset to the default""" - app.icon = None - assert isinstance(app.icon, toga.Icon) - assert app.icon.path == Path("resources/test-app") - assert_action_performed_with(app, "set_icon", icon=toga.Icon.DEFAULT_ICON) - - -def test_default_icon_non_script(monkeypatch, app): - """The app icon can be reset to the default""" - # Patch sys.executable so the test looks like it's running as a packaged binary - monkeypatch.setattr(sys, "executable", "/path/to/App") - - app.icon = None - assert isinstance(app.icon, toga.Icon) - assert app.icon.path == Path("resources/test-app") - assert_action_performed_with(app, "set_icon", icon=toga.Icon(None)) - - def test_current_window(app): """The current window can be set and changed.""" other_window = toga.Window() diff --git a/core/tests/conftest.py b/core/tests/conftest.py index a67c36929..20ea40250 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -28,4 +28,10 @@ class TestApp(toga.App): @pytest.fixture def app(event_loop): + # The app icon is cached; purge the app icon cache if it exists + try: + del toga.Icon.__APP_ICON + except AttributeError: + pass + return TestApp(formal_name="Test App", app_id="org.beeware.toga.test-app") diff --git a/core/tests/test_icons.py b/core/tests/test_icons.py index bbfee1a46..f3773e2b7 100644 --- a/core/tests/test_icons.py +++ b/core/tests/test_icons.py @@ -1,9 +1,11 @@ +import sys from pathlib import Path import pytest import toga import toga_dummy +from toga.icons import _APP_ICON from toga_dummy.icons import Icon as DummyIcon APP_RESOURCES = Path(__file__).parent / "resources" @@ -141,16 +143,21 @@ def test_create_fallback_variants(monkeypatch, app, capsys): ) -def test_create_app_icon(app, capsys): +def test_create_app_icon(monkeypatch, app, capsys): """The app icon can be constructed""" + # Patch the app name to a name that will exist + monkeypatch.setattr(app, "_app_name", "sample") + # When running under pytest, code will identify as running as a script + # Load the app default icon. - icon = toga.Icon(None) + icon = toga.Icon(_APP_ICON) # The impl is the app icon. assert icon._impl is not None assert icon._impl.interface != toga.Icon.DEFAULT_ICON - assert icon._impl.path == "" + assert icon.path == Path("resources/sample") + assert icon._impl.path == Path(APP_RESOURCES / "sample.png") # No warning was printed, as the app icon exists. assert capsys.readouterr().out == "" @@ -158,43 +165,54 @@ def test_create_app_icon(app, capsys): def test_create_app_icon_missing(monkeypatch, app, capsys): """The app icon can be constructed""" - # Prime the dummy so the it has no app icon - monkeypatch.setattr(DummyIcon, "ICON_EXISTS", False) - # When running under pytest, code will identify as running as a script + # Load the app default icon. - icon = toga.Icon(None) + icon = toga.Icon(_APP_ICON) # The impl is the app icon. assert icon._impl is not None assert icon._impl.interface == toga.Icon.DEFAULT_ICON - # A warning was printed; allow for windows separators - assert "WARNING: Can't find app icon" in capsys.readouterr().out.replace("\\", "/") + # No warning was printed, as we're running as a script. + assert capsys.readouterr().out == "" -@pytest.mark.parametrize( - "icon_name", - [ - None, - "resources/missing", - ], -) -def test_create_icon_explicit_fallback(monkeypatch, app, icon_name, capsys): - """If an explicit default is provided, and the icon can't be found there's no fallback warning.""" - # Specify a non-default icon that we know exists - default = toga.Icon("resources/red") +def test_create_app_icon_non_script(monkeypatch, app, capsys): + """The icon from the binary is used when running as a packaged binary""" + # Patch sys.executable so the test looks like it's running as a packaged binary + monkeypatch.setattr(sys, "executable", "/path/to/App") - # Now set the dummy so that it has no app icon + # Load the app default icon + icon = toga.Icon(_APP_ICON) + + assert isinstance(icon, toga.Icon) + # App icon path reports as `resources/`; impl is the app icon + assert icon.path == Path("resources/icons") + assert icon._impl.path == "" + + # No warning was printed, as we're running as a script. + assert capsys.readouterr().out == "" + + +def test_create_app_icon_missing_non_script(monkeypatch, app, capsys): + """The binary executableThe app icon can be reset to the default""" + # Prime the dummy so the app icon cannot be loaded monkeypatch.setattr(DummyIcon, "ICON_EXISTS", False) - icon = toga.Icon(icon_name, default=default) + # Patch sys.executable so the test looks like it's running as a packaged binary + monkeypatch.setattr(sys, "executable", "/path/to/App") - assert icon._impl is not None - assert icon._impl.interface == default + # Load the app default icon + icon = toga.Icon(_APP_ICON) - # No warning was printed - assert capsys.readouterr().out == "" + assert isinstance(icon, toga.Icon) + # App icon path reports as `resources/`; impl is the default toga icon + assert icon.path == Path("resources/icons") + assert icon._impl.path == Path(TOGA_RESOURCES / "toga.png") + + # A warning was printed; allow for windows separators + assert "WARNING: Can't find app icon" in capsys.readouterr().out.replace("\\", "/") @pytest.mark.parametrize(