Skip to content

Commit

Permalink
Merge pull request beeware#2260 from freakboy3742/platform-icons
Browse files Browse the repository at this point in the history
Add the ability to specify platform-specific icons
  • Loading branch information
mhsmith authored Dec 16, 2023
2 parents e09fadd + c8c9c15 commit 54c4f01
Show file tree
Hide file tree
Showing 40 changed files with 220 additions and 65 deletions.
File renamed without changes
12 changes: 11 additions & 1 deletion android/tests_backend/icons.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from pathlib import Path

import pytest
from android.graphics import Bitmap

import toga_android

from .probe import BaseProbe


Expand All @@ -26,4 +30,10 @@ def assert_icon_content(self, path):
pytest.fail("Unknown icon resource")

def assert_default_icon_content(self):
assert self.icon._impl.path == self.app.paths.toga / "resources/toga.png"
assert (
self.icon._impl.path
== Path(toga_android.__file__).parent / "resources/toga.png"
)

def assert_platform_icon_content(self):
assert self.icon._impl.path == self.app.paths.app / "resources/logo-android.png"
1 change: 1 addition & 0 deletions changes/2260.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Apps can now specify platform-specific icon resources by appending the platform name (e.g., ``-macOS`` or ``-windows``) to the icon filename.
Empty file.
File renamed without changes.
6 changes: 4 additions & 2 deletions cocoa/src/toga_cocoa/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ def refreshContent(self) -> None:


class OptionContainer(Widget):
uses_icons = False

def create(self):
self.native = TogaTabView.alloc().init()
self.native.interface = self.interface
Expand Down Expand Up @@ -117,8 +119,8 @@ def set_option_text(self, index, value):
tabview = self.native.tabViewItemAtIndex(index)
tabview.label = value

def set_option_icon(self, index, value):
# Icons aren't supported
def set_option_icon(self, index, value): # pragma: nocover
# This shouldn't ever be invoked, but it's included for completeness.
pass

def get_option_icon(self, index):
Expand Down
11 changes: 10 additions & 1 deletion cocoa/tests_backend/icons.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from pathlib import Path

import pytest

import toga_cocoa
from toga_cocoa.libs import NSImage

from .probe import BaseProbe
Expand Down Expand Up @@ -28,4 +31,10 @@ def assert_icon_content(self, path):
pytest.fail("Unknown icon resource")

def assert_default_icon_content(self):
assert self.icon._impl.path == self.app.paths.toga / "resources/toga.icns"
assert (
self.icon._impl.path
== Path(toga_cocoa.__file__).parent / "resources/toga.icns"
)

def assert_platform_icon_content(self):
assert self.icon._impl.path == self.app.paths.app / "resources/logo-macOS.icns"
44 changes: 28 additions & 16 deletions core/src/toga/icons.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import sys
import warnings
from pathlib import Path
from typing import TYPE_CHECKING

Expand All @@ -19,6 +20,7 @@
class cachedicon:
def __init__(self, f):
self.f = f
self.__doc__ = f.__doc__

def __get__(self, obj, owner):
# If you ask for Icon.CACHED_ICON, obj is None, and owner is the Icon class
Expand All @@ -40,15 +42,23 @@ def __get__(self, obj, owner):
class Icon:
@cachedicon
def TOGA_ICON(cls) -> Icon:
return Icon("resources/toga", system=True)
"""**DEPRECATED** - Use ``DEFAULT_ICON``, or your own icon."""
warnings.warn(
"TOGA_ICON has been deprecated; Use DEFAULT_ICON, or your own icon.",
DeprecationWarning,
)

return Icon("toga", system=True)

@cachedicon
def DEFAULT_ICON(cls) -> Icon:
return Icon("resources/toga", system=True)
"""The default icon used as a fallback."""
return Icon("toga", system=True)

@cachedicon
def OPTION_CONTAINER_DEFAULT_TAB_ICON(cls) -> Icon:
return Icon("resources/optioncontainer-tab", system=True)
"""The default icon used to decorate option container tabs."""
return Icon("optioncontainer-tab", system=True)

def __init__(
self,
Expand All @@ -64,14 +74,15 @@ def __init__(
This base filename should *not* contain an extension. If an extension is
specified, it will be ignored.
:param system: **For internal use only**
"""
self.path = Path(path)
self.system = system

self.factory = get_platform_factory()
try:
if self.system:
resource_path = toga.App.app.paths.toga
resource_path = Path(self.factory.__file__).parent / "resources"
else:
resource_path = toga.App.app.paths.app

Expand All @@ -97,20 +108,21 @@ def __init__(
self._impl = self.DEFAULT_ICON._impl

def _full_path(self, size, extensions, resource_path):
platform = toga.platform.current_platform
for extension in extensions:
if size:
icon_path = (
resource_path
/ self.path.parent
/ f"{self.path.stem}-{size}{extension}"
)
for filename in (
[
f"{self.path.stem}-{platform}-{size}{extension}",
f"{self.path.stem}-{size}{extension}",
]
if size
else []
) + [
f"{self.path.stem}-{platform}{extension}",
f"{self.path.stem}{extension}",
]:
icon_path = resource_path / self.path.parent / filename
if icon_path.exists():
return icon_path

icon_path = (
resource_path / self.path.parent / f"{self.path.stem}{extension}"
)
if icon_path.exists():
return icon_path

raise FileNotFoundError(f"Can't find icon {self.path}")
24 changes: 13 additions & 11 deletions core/src/toga/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import toga
from toga.handlers import wrapped_handler
from toga.platform import get_platform_factory

from .base import Widget

Expand Down Expand Up @@ -143,17 +144,18 @@ def icon(self) -> toga.Icon:

@icon.setter
def icon(self, icon_or_name: IconContent):
if icon_or_name is None:
icon = None
elif isinstance(icon_or_name, toga.Icon):
icon = icon_or_name
else:
icon = toga.Icon(icon_or_name)

if hasattr(self, "_icon"):
self._icon = icon
else:
self._interface._impl.set_option_icon(self.index, icon)
if get_platform_factory().OptionContainer.uses_icons:
if icon_or_name is None:
icon = None
elif isinstance(icon_or_name, toga.Icon):
icon = icon_or_name
else:
icon = toga.Icon(icon_or_name)

if hasattr(self, "_icon"):
self._icon = icon
else:
self._interface._impl.set_option_icon(self.index, icon)

@property
def index(self) -> int | None:
Expand Down
Binary file added core/tests/resources/toga.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
45 changes: 37 additions & 8 deletions core/tests/test_icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import pytest

import toga
import toga_dummy
from toga_dummy.icons import Icon as DummyIcon

APP_RESOURCES = Path(__file__).parent / "resources"
TOGA_RESOURCES = Path(toga.__file__).parent / "resources"
TOGA_RESOURCES = Path(toga_dummy.__file__).parent / "resources"


class MyApp(toga.App):
Expand All @@ -26,11 +27,11 @@ def app():
# Absolute path (points at a file in the system resource folder,
# but that's just because it's a location we know exists.)
(
Path(__file__).parent.parent / "src/toga/resources/toga",
TOGA_RESOURCES / "toga",
False,
None,
[".png"],
Path(__file__).parent.parent / "src/toga/resources/toga.png",
TOGA_RESOURCES / "toga.png",
),
# PNG format
("resources/red", False, None, [".png"], APP_RESOURCES / "red.png"),
Expand Down Expand Up @@ -68,10 +69,26 @@ def app():
[".bmp", ".png"],
APP_RESOURCES / "orange.bmp",
),
# Relative path, platform-specific resource
(
Path("resources/widget"),
False,
None,
[".png"],
APP_RESOURCES / "widget-dummy.png",
),
# Relative path as string, platform-specific resource
(
"resources/widget",
False,
None,
[".png"],
APP_RESOURCES / "widget-dummy.png",
),
# Relative path, system resource
(Path("resources/toga"), True, None, [".png"], TOGA_RESOURCES / "toga.png"),
(Path("toga"), True, None, [".png"], TOGA_RESOURCES / "toga.png"),
# Relative path as string, system resource
(Path("resources/toga"), True, None, [".png"], TOGA_RESOURCES / "toga.png"),
("toga", True, None, [".png"], TOGA_RESOURCES / "toga.png"),
],
)
def test_create(monkeypatch, app, path, system, sizes, extensions, final_paths):
Expand All @@ -80,6 +97,9 @@ def test_create(monkeypatch, app, path, system, sizes, extensions, final_paths):
monkeypatch.setattr(DummyIcon, "SIZES", sizes)
monkeypatch.setattr(DummyIcon, "EXTENSIONS", extensions)

# monkeypatch the current platform to report as dummy
monkeypatch.setattr(toga.platform, "current_platform", "dummy")

icon = toga.Icon(path, system=system)

# Icon is bound
Expand All @@ -102,9 +122,8 @@ def test_create_fallback(app):
@pytest.mark.parametrize(
"name, path",
[
("DEFAULT_ICON", "resources/toga"),
("TOGA_ICON", "resources/toga"),
("OPTION_CONTAINER_DEFAULT_TAB_ICON", "resources/optioncontainer-tab"),
("DEFAULT_ICON", "toga"),
("OPTION_CONTAINER_DEFAULT_TAB_ICON", "optioncontainer-tab"),
],
)
def test_cached_icons(app, name, path):
Expand All @@ -115,3 +134,13 @@ def test_cached_icons(app, name, path):

# Retrieve the icon a second time; The same instance is returned.
assert id(getattr(toga.Icon, name)) == id(icon)


def test_deprecated_icons(app):
"""Deprecated icons are still available"""
with pytest.warns(DeprecationWarning):
icon = toga.Icon.TOGA_ICON
assert icon.path == Path("toga")

# Retrieve the icon a second time; The same instance is returned.
assert id(toga.Icon.TOGA_ICON) == id(icon)
2 changes: 1 addition & 1 deletion core/tests/widgets/test_imageview.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def test_create_empty(widget):
assert widget.image is None


ABSOLUTE_FILE_PATH = Path(toga.__file__).parent / "resources/toga.png"
ABSOLUTE_FILE_PATH = Path(__file__).parent.parent / "resources/toga.png"


def test_create_from_toga_image(app):
Expand Down
28 changes: 28 additions & 0 deletions core/tests/widgets/test_optioncontainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

import toga
from toga_dummy import factory as dummy_factory
from toga_dummy.utils import (
EventLog,
assert_action_not_performed,
Expand Down Expand Up @@ -409,6 +410,33 @@ def test_item_icon(optioncontainer, bare_item):
)


@pytest.mark.parametrize("bare_item", [True, False])
def test_item_icon_disabled(monkeypatch, optioncontainer, bare_item):
"""The icon of an item won't be set if icons aren't in use."""
# monkeypatch the OptionContainer class to disable the use of icons
monkeypatch.setattr(dummy_factory.OptionContainer, "uses_icons", False)

if bare_item:
item = toga.OptionItem("title", toga.Box())
else:
item = optioncontainer.content[0]

# Icon is initially empty
assert item.icon is None

# Try to set an icon
item.icon = "test-icon"

# Icon is still none
assert item.icon is None

# Add a new content item with an icon
optioncontainer.content.append("New content", toga.Box(), icon="new-icon")

# Icon is still none
assert optioncontainer.content["New content"].icon is None


def test_optionlist_repr(optioncontainer):
"""OptionContainer content has a helpful repr"""
assert (
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/api/containers/optioncontainer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ Notes
by index, user re-ordering is ignored; the logical order as configured in Toga itself
is used to identify tabs.

* Icons for iOS OptionContainer tabs should be 25x25px alpha masks.

Reference
---------

Expand Down
34 changes: 26 additions & 8 deletions docs/reference/api/resources/icons.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Usage

The filename specified for an icon should be specified *without* an extension; the
platform will determine an appropriate extension, and may also modify the name of the
icon to include a size qualifier.
icon to include a platform and/or size qualifier.

The following formats are supported (in order of preference):

Expand All @@ -25,14 +25,32 @@ The following formats are supported (in order of preference):
* **GTK** - PNG, ICO, ICNS. 32px and 72px variants of each icon can be provided;
* **Windows** - ICO, PNG, BMP

The first matching icon of the most specific size will be used. For example, on Windows,
specifying an icon of ``myicon`` will cause Toga to look for ``myicon.ico``, then
``myicon.png``, then ``myicon.bmp``. On GTK, Toga will look for ``myicon-72.png`` and
``myicon-32.png``, then ``myicon.png``, then ``myicon-72.ico`` and ``myicon-32.ico``, and so on.
The first matching icon of the most specific platform, with the most specific
size will be used. For example, on Windows, specifying an icon of ``myicon``
will cause Toga to look for (in order):

An icon is **guaranteed** to have an implementation. If you specify a path and no
matching icon can be found, Toga will output a warning to the console, and load a
default "Tiberius the yak" icon.
* ``myicon-windows.ico``
* ``myicon.ico``
* ``myicon-windows.png``
* ``myicon.png``
* ``myicon-windows.bmp``
* ``myicon.bmp``

On GTK, Toga will look for (in order):

* ``myicon-linux-72.png``
* ``myicon-72.png``
* ``myicon-linux-32.png``
* ``myicon-32.png``
* ``myicon-linux.png``
* ``myicon.png``
* ``myicon-linux-72.ico``
* ``myicon-72.ico``
* ``myicon-linux-32.ico``, and so on.

An icon is **guaranteed** to have an implementation, regardless of the path
specified. If you specify a path and no matching icon can be found, Toga will
output a warning to the console, and load a default "Tiberius the yak" icon.

Reference
---------
Expand Down
Loading

0 comments on commit 54c4f01

Please sign in to comment.