Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use the application binary icon when available #2527

Merged
merged 22 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b43c7bd
Use the application binary icon as a default, if possible.
freakboy3742 Apr 23, 2024
8bd38ff
Add default application icon handling for macOS.
freakboy3742 Apr 23, 2024
52555a5
Ensure app icon is modified when set.
freakboy3742 Apr 23, 2024
68ea0ba
Add GTK implementation of app icon fixes.
freakboy3742 Apr 24, 2024
8da63e1
Add winforms implementation of app icon fixes.
freakboy3742 Apr 24, 2024
94cc9b9
Add changenote.
freakboy3742 Apr 24, 2024
e078bc4
Add Android, iOS, Web and Textual fallback behavior for app icons.
freakboy3742 Apr 24, 2024
3f0e89f
Fix spelling and platform inconsistency.
freakboy3742 Apr 24, 2024
73477df
Correct color references to allow for colorspace on macOS.
freakboy3742 Apr 24, 2024
cfedbbf
Correct GTK testbed handling of native widgets.
freakboy3742 Apr 24, 2024
814d866
Exclude coverage for mobile runtime icons.
freakboy3742 Apr 24, 2024
9376a2f
More color inconsistencies.
freakboy3742 Apr 24, 2024
b671127
Merge branch 'main' into default-icon
freakboy3742 Apr 24, 2024
d9e3e4f
One more color inconsistency.
freakboy3742 Apr 24, 2024
091dc00
Modify GTK icon fallback handling.
freakboy3742 Apr 25, 2024
1c4fcee
Modified fallback behavior for app icons.
freakboy3742 Apr 26, 2024
4790109
Exhaust size-specific options before using generic options.
freakboy3742 Apr 26, 2024
690d066
Merge branch 'main' into default-icon
freakboy3742 Apr 30, 2024
70e7df5
Clarified docs around icon defintions.
freakboy3742 Apr 30, 2024
790e03e
Define and use toga.Icon.APP_ICON as the app icon.
freakboy3742 Apr 30, 2024
2b8a02f
Correct testbed tests of app icons.
freakboy3742 Apr 30, 2024
789edd5
Allow GTK app icon tests to pass in dev mode.
freakboy3742 Apr 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ def main_loop(self):
# of the Android Activity system.
self.create()

def set_icon(self, icon):
# Android apps don't have runtime icons, so this can't be invoked
pass # pragma: no cover

def set_main_window(self, window):
pass

Expand Down
4 changes: 4 additions & 0 deletions android/src/toga_android/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ class Icon:
def __init__(self, interface, path):
self.interface = interface
self.interface._impl = self

if path is None:
raise FileNotFoundError("No runtime app icon")

self.path = path

self.native = BitmapFactory.decodeFile(str(path))
Expand Down
3 changes: 3 additions & 0 deletions android/tests_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ def cache_path(self):
def logs_path(self):
return Path(self.get_app_context().getFilesDir().getPath()) / "log"

def assert_app_icon(self, icon):
xfail("Android apps don't have app icons at runtime")

def _menu_item(self, path):
menu = self.main_window_probe._native_menu()
for i_path, label in enumerate(path):
Expand Down
3 changes: 3 additions & 0 deletions android/tests_backend/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ def assert_default_icon_content(self):

def assert_platform_icon_content(self):
assert self.icon._impl.path == self.app.paths.app / "resources/logo-android.png"

def assert_app_icon_content(self):
pytest.xfail("Android apps don't have app icons at runtime")
1 change: 1 addition & 0 deletions changes/2527.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When a Toga apps is packaged as a redistributable binary, and no icon is explicitly configured, Toga will now use the binary's icon as the app icon. This means it is no longer necessary to include the app icon as data in a ``resources`` folder if you are packaging your app for distribution.
13 changes: 12 additions & 1 deletion cocoa/src/toga_cocoa/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ def create(self):
self.native = NSApplication.sharedApplication
self.native.setActivationPolicy(NSApplicationActivationPolicyRegular)

self.native.setApplicationIconImage_(self.interface.icon._impl.native)
# The app icon been set *before* the app instance is created. However, we only
# need to set the icon on the app if it has been explicitly defined; the default
# icon is... the default. We can't test this branch in the testbed.
if self.interface.icon._impl.path:
self.set_icon(self.interface.icon) # pragma: no cover

self.resource_path = os.path.dirname(
os.path.dirname(NSBundle.mainBundle.bundlePath)
Expand Down Expand Up @@ -421,6 +425,13 @@ def exit(self): # pragma: no cover
def main_loop(self):
self.loop.run_forever(lifecycle=CocoaLifecycle(self.native))

def set_icon(self, icon):
# If the icon is a path, it's an explicit icon; otherwise its the default icon
if icon._impl.path:
self.native.setApplicationIconImage(icon._impl.native)
else:
self.native.setApplicationIconImage(None)

def set_main_window(self, window):
pass

Expand Down
32 changes: 24 additions & 8 deletions cocoa/src/toga_cocoa/icons.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pathlib import Path

from rubicon.objc import NSSize

from toga_cocoa.libs import NSImage
from toga_cocoa.libs import NSBundle, NSImage


class Icon:
Expand All @@ -10,17 +12,31 @@ class Icon:
def __init__(self, interface, path):
self.interface = interface
self.interface._impl = self
self.path = path

if path is None:
# Look to the app bundle, and get the icon. Set self.path as None
# as an indicator that this is the app's default icon.
bundle_icon = Path(
NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleIconFile")
)
path = NSBundle.mainBundle.pathForResource(
bundle_icon.stem,
ofType=bundle_icon.suffix,
)
self.path = None
else:
self.path = path

try:
# We *should* be able to do a direct NSImage.alloc.init...(), but if the
# image file is invalid, the init fails, returns NULL, and releases the
# Objective-C object. Since we've created an ObjC instance, when the object
# passes out of scope, Rubicon tries to free it, which segfaults.
# Objective-C object. Since we've created an ObjC instance, when the
# object passes out of scope, Rubicon tries to free it, which segfaults.
# To avoid this, we retain result of the alloc() (overriding the default
# Rubicon behavior of alloc), then release that reference once we're done.
# If the image was created successfully, we temporarily have a reference
# count that is 1 higher than it needs to be; if it fails, we don't end up
# with a stray release.
# Rubicon behavior of alloc), then release that reference once we're
# done. If the image was created successfully, we temporarily have a
# reference count that is 1 higher than it needs to be; if it fails, we
# don't end up with a stray release.
image = NSImage.alloc().retain()
self.native = image.initWithContentsOfFile(str(path))
if self.native is None:
Expand Down
37 changes: 37 additions & 0 deletions cocoa/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from pathlib import Path

import PIL.Image
from rubicon.objc import NSPoint, ObjCClass, objc_id, send_message

import toga
from toga_cocoa.keys import cocoa_key, toga_key
from toga_cocoa.libs import (
NSApplication,
Expand Down Expand Up @@ -58,6 +60,41 @@ def content_size(self, window):
window.content._impl.native.frame.size.height,
)

def assert_app_icon(self, icon):
# We have no real way to check we've got the right icon; use pixel peeping as a
# guess. Construct a PIL image from the current icon.
img = toga.Image(
NSApplication.sharedApplication.applicationIconImage
).as_format(PIL.Image.Image)

# Due to icon resizing and colorspace issues, the exact pixel colors are
# inconsistent, so multiple values must be provided for test purposes.
if icon:
# The explicit alt icon has blue background, with green at a point 1/3 into
# the image
assert img.getpixel((5, 5)) in {
(205, 226, 243, 255),
(211, 226, 243, 255),
(211, 230, 245, 255),
}
mid_color = img.getpixel((img.size[0] // 3, img.size[1] // 3))
assert mid_color in {
(0, 204, 9, 255),
(6, 204, 8, 255),
(14, 197, 8, 255),
(105, 192, 32, 255),
}
else:
# The default icon is transparent background, and brown in the center.
assert img.getpixel((5, 5))[3] == 0
mid_color = img.getpixel((img.size[0] // 2, img.size[1] // 2))
assert mid_color in {
(130, 100, 57, 255),
(130, 109, 66, 255),
(138, 108, 64, 255),
(149, 119, 73, 255),
}

def _menu_item(self, path):
main_menu = self.app._impl.native.mainMenu

Expand Down
14 changes: 14 additions & 0 deletions cocoa/tests_backend/icons.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from pathlib import Path

import PIL.Image
import pytest

import toga
import toga_cocoa
from toga_cocoa.libs import NSImage

Expand Down Expand Up @@ -38,3 +40,15 @@ def assert_default_icon_content(self):

def assert_platform_icon_content(self):
assert self.icon._impl.path == self.app.paths.app / "resources/logo-macOS.icns"

def assert_app_icon_content(self):
# We have no real way to check we've got the right icon; use pixel peeping as a
# guess. Construct a PIL image from the current icon.
img = toga.Image(self.icon._impl.native).as_format(PIL.Image.Image)

# The default icon is transparent background, and brown in the center.
# Due to icon resizing, the exact pixel color is inconsistent, depending
# on whether it's the default or the value after a reset.
assert img.getpixel((5, 5))[3] == 0
mid_color = img.getpixel((img.size[0] // 2, img.size[1] // 2))
assert mid_color in {(130, 100, 57, 255), (130, 109, 66, 255)}
52 changes: 40 additions & 12 deletions core/src/toga/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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
Expand Down Expand Up @@ -350,9 +351,12 @@ def __init__(
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 attempt to load an icon from ``resources/app_name``, where ``app_name``
is defined above. If no resource matching this name can be found, a warning
will be printed, and the app will fall back to a default icon.
will attempt to load an icon from the following sources, in order:

* ``resources/<app_name>``, where ``app_name`` is as defined above
mhsmith marked this conversation as resolved.
Show resolved Hide resolved
* 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
Expand Down Expand Up @@ -472,12 +476,7 @@ def __init__(
# Instantiate the paths instance for this app.
self._paths = Paths()

# If an icon (or icon name) has been explicitly provided, use it;
# otherwise, the icon will be based on the distribution name.
if icon:
self.icon = icon
else:
self.icon = f"resources/{app_name}"
self.icon = icon

self.on_exit = on_exit

Expand Down Expand Up @@ -543,20 +542,49 @@ def home_page(self) -> str | None:
def icon(self) -> Icon:
"""The Icon for the app.

Can be specified as any valid :any:`icon content <IconContent>`.
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:

When setting the icon, you can provide either an :any:`Icon` instance, or a
path that will be passed to the ``Icon`` constructor.
* ``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

@icon.setter
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)

try:
self._impl.set_icon(self._icon)
except AttributeError:
# The first time the icon is set, it is *before* the impl has been created,
# so that the app instance can be instantiated with the correct icon.
pass

@property
def id(self) -> str:
"""**DEPRECATED** – Use :any:`app_id`."""
Expand Down
Loading
Loading