Modified fallback behavior for app icons.

This commit is contained in:
Russell Keith-Magee 2024-04-26 11:00:30 +08:00
parent 091dc00e6a
commit 1c4fceec1f
No known key found for this signature in database
GPG key ID: 3D2DAB6A37BB5BC3
6 changed files with 117 additions and 81 deletions

View file

@ -16,6 +16,7 @@ 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,8 +351,12 @@ class App:
distribution name of ``my-app``.
#. As a last resort, the name ``toga``.
:param icon: The :any:`icon <IconContent>` for the app. If not provided, Toga
will use a default icon. See the definition of :class:`~toga.Icon` for how
misconfigured and default icons are handled.
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.
: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
@ -540,6 +545,14 @@ class 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.
"""
return self._icon
@ -547,6 +560,21 @@ class App:
def icon(self, icon_or_name: IconContent | None) -> 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

@ -65,52 +65,25 @@ class Icon:
path: str | Path | None,
*,
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: A specifier for the icon to load.
If the icon is specified as a string or path, the value is used as a base
filename. This base filename 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 a path is provided, but an appropriate icon matching
that base name cannot be found, a warning will be displayed, and the default
Toga icon will be used.
If the icon is specified as:any:`None`, a default icon will be used.
If the app is being run as a bare Python script, the default icon will be
loaded as the app-relative base filename ``resources/<app_name>``. If a
platform-appropriate icon file matching this resource doesn't exist, the
default Toga icon will be used, but no warning will be printed.
Otherwise, the default icon is the icon that is used for the application
binary.
: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.
:param system: **For internal use only**
:param default: **For internal use only**
"""
self.factory = get_platform_factory()
silent_fallback = False
if path is None:
# Don't ever report an app icon failing to load.
silent_fallback = True
if Path(sys.executable).stem in {
"python",
f"python{sys.version_info.major}",
f"python{sys.version_info.major}.{sys.version_info.minor}",
}:
# If there's no explicit icon specified, and app is running as a python
# script, use a default icon name. If this icon name doesn't exist, we
# don't need to warn about a missing icon, as it's a value by
# convention, rather than an explicit setting.
path = f"resources/{toga.App.app.app_name}"
try:
if path is None:
# If path is still None, load the application binary's icon
# If path is None, load the application binary's icon
self.path = None
self._impl = self.factory.Icon(interface=self, path=None)
else:
@ -144,11 +117,18 @@ class Icon:
self._impl = self.factory.Icon(interface=self, path=full_path)
except FileNotFoundError:
if not silent_fallback:
print(
f"WARNING: Can't find icon {self.path}; falling back to default icon"
)
self._impl = self.DEFAULT_ICON._impl
# 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
else:
self._impl = default._impl
def _full_path(self, size, extensions, resource_path):
platform = toga.platform.current_platform
@ -171,4 +151,4 @@ class Icon:
raise FileNotFoundError(f"Can't find icon {self.path}")
def __eq__(self, other):
return isinstance(other, Icon) and other.path == self.path
return isinstance(other, Icon) and other._impl.path == self._impl.path

View file

@ -366,7 +366,20 @@ def test_icon(app, construct):
assert app.icon.path == Path("path/to/icon")
assert_action_performed_with(app, "set_icon", icon=toga.Icon("path/to/icon"))
# Revert to default 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")

View file

@ -1,4 +1,3 @@
import sys
from pathlib import Path
import pytest
@ -142,53 +141,62 @@ def test_create_fallback_variants(monkeypatch, app, capsys):
)
def test_create_default_icon_script_fallback(app, capsys):
"""If the app is running as a script, but no icon is provided, the default icon is used"""
def test_create_app_icon(app, capsys):
"""The app icon can be constructed"""
# When running under pytest, code will identify as running as a script
# Load the app default icon.
icon = toga.Icon(None)
# The impl is the default 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>"
# No warning was printed, as the app icon exists.
assert capsys.readouterr().out == ""
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)
# The impl is the app icon.
assert icon._impl is not None
assert icon._impl.interface == toga.Icon.DEFAULT_ICON
# No warning is printed
assert capsys.readouterr().out == ""
# A warning was printed; allow for windows separators
assert "WARNING: Can't find app icon" in capsys.readouterr().out.replace("\\", "/")
def test_create_default_icon_script(app, capsys):
"""If the app is running as a script, and an icon is provided, it is used"""
# When running under pytest, code will identify as running as a script
# Set the app name to something that will result in finding an icon
app._app_name = "sample"
@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")
# Load the app default icon
icon = toga.Icon(None)
# Now set the dummy so that it has no app icon
monkeypatch.setattr(DummyIcon, "ICON_EXISTS", False)
icon = toga.Icon(icon_name, default=default)
assert icon._impl is not None
assert icon._impl.interface == default
# The icon *isn't* the Toga default; it's sample.png
assert icon._impl.interface != toga.Icon.DEFAULT_ICON
assert icon._impl.path == APP_RESOURCES / "sample.png"
# No warning is printed
# No warning was printed
assert capsys.readouterr().out == ""
def test_create_default_icon(monkeypatch, app):
"""If the app is running as a script, but no icon is provided, the default icon is used"""
# Patch sys.executable so the test looks like it's running as a packaged binary
monkeypatch.setattr(sys, "executable", "/path/to/App")
icon = toga.Icon(None)
assert icon._impl is not None
# In the dummy backend, the default icon app icon has a path of None.
assert icon._impl.interface != toga.Icon.DEFAULT_ICON
assert icon._impl.path is None
@pytest.mark.parametrize(
"name, path",
[

View file

@ -38,7 +38,8 @@ The following formats are supported (in order of preference):
* **Android** - PNG
* **iOS** - ICNS, PNG, BMP, ICO
* **macOS** - ICNS, PNG, PDF
* **GTK** - PNG, ICO, ICNS; 512, 256, 128, 72, 64, 32, and 16px variants of each icon can be provided;
* **GTK** - PNG, ICO, ICNS; 512, 256, 128, 72, 64, 32, and 16px variants of each icon
can be provided;
* **Windows** - ICO, PNG, BMP
The first matching icon of the most specific platform, with the most specific

View file

@ -2,12 +2,18 @@ from .utils import LoggedObject
class Icon(LoggedObject):
ICON_EXISTS = True
EXTENSIONS = [".png", ".ico"]
SIZES = None
def __init__(self, interface, path):
super().__init__()
self.interface = interface
self.path = path
if path == {}:
if not self.ICON_EXISTS:
raise FileNotFoundError("Couldn't find icon")
elif path is None:
self.path = "<APP ICON>"
elif path == {}:
raise FileNotFoundError("No image variants found")
else:
self.path = path