Define and use toga.Icon.APP_ICON as the app icon.

This commit is contained in:
Russell Keith-Magee 2024-04-30 11:19:37 +08:00
parent 70e7df5405
commit 790e03e2f3
No known key found for this signature in database
GPG key ID: 3D2DAB6A37BB5BC3
5 changed files with 135 additions and 127 deletions

View file

@ -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 <IconContent>` 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 <IconContent>` 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 <IconContent>`, 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/<app_name>``, 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 <IconContent>`.
"""
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)

View file

@ -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 = "<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/<app name>`` (where ``<app
name>`` 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/<app name>`` 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

View file

@ -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()

View file

@ -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")

View file

@ -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 == "<APP ICON>"
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/<app_name>`; impl is the app icon
assert icon.path == Path("resources/icons")
assert icon._impl.path == "<APP ICON>"
# 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/<app_name>`; 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(