Modified fallback behavior for app icons.
This commit is contained in:
parent
091dc00e6a
commit
1c4fceec1f
6 changed files with 117 additions and 81 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue