Skip to content

Commit

Permalink
Merge pull request #2527 from freakboy3742/default-icon
Browse files Browse the repository at this point in the history
Use the application binary icon when available
  • Loading branch information
mhsmith authored Apr 30, 2024
2 parents 5242026 + 789edd5 commit 3193372
Show file tree
Hide file tree
Showing 80 changed files with 548 additions and 110 deletions.
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
38 changes: 38 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,42 @@ 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, 107, 64, 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)}
26 changes: 13 additions & 13 deletions core/src/toga/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,8 @@ def __init__(
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. 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.
: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
Expand Down Expand Up @@ -472,12 +470,10 @@ 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
if icon is None:
self.icon = Icon.APP_ICON
else:
self.icon = f"resources/{app_name}"
self.icon = icon

self.on_exit = on_exit

Expand Down Expand Up @@ -544,19 +540,23 @@ def icon(self) -> Icon:
"""The Icon for the app.
Can be specified as any valid :any:`icon content <IconContent>`.
When setting the icon, you can provide either an :any:`Icon` instance, or a
path that will be passed to the ``Icon`` constructor.
"""
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
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

0 comments on commit 3193372

Please sign in to comment.