Define and use toga.Icon.APP_ICON as the app icon.
This commit is contained in:
parent
70e7df5405
commit
790e03e2f3
5 changed files with 135 additions and 127 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue