frus_shell/icon.rs
1//! The application's **icon**: the frus logo by default, the developer's when they say so.
2//!
3//! A new application should look finished the moment it runs, before anyone has drawn an
4//! icon: its window has one, its browser tab has one. So every application has the frus logo
5//! unless it says otherwise — and it can say otherwise, or say *none*:
6//!
7//! ```
8//! use frus_shell::AppIcon;
9//!
10//! // The default: the frus logo. Nothing to write.
11//! let _ = AppIcon::frus();
12//! // Its own, from a PNG compiled into the binary.
13//! # let png: &'static [u8] = &[];
14//! let _ = AppIcon::from_png(png);
15//! // No icon of the framework's at all: the platform's plain default.
16//! let _ = AppIcon::none();
17//! ```
18//!
19//! Where it shows, and where it cannot:
20//!
21//! - **Desktop** — the window's icon (its title bar and, on Windows, the taskbar). Wayland and
22//! macOS take their icon from the application bundle, not from the window, and ignore this.
23//! - **Web** — the tab's icon. An icon the *page* declares (`<link rel="icon" href="…">`) wins
24//! over the default, since the page is the more specific author; an icon the application
25//! names explicitly wins over the page's.
26//! - **Android** — the launcher icon is the manifest's, fixed when the package is built, and
27//! not the running code's to change. The template and the demo ship the frus logo as
28//! `res/mipmap-*/ic_launcher.png` and name it in `Cargo.toml`; replace those five files to
29//! change it.
30//!
31//! The logo is embedded at 256 px (about 22 kB) and not at the 1024 px of the artwork, which is
32//! more than any of these places draws and would add most of a megabyte to every binary.
33
34use std::borrow::Cow;
35
36/// The frus logo at 256 px, as a PNG: the default icon. Not compiled into an Android build,
37/// which has no window or tab to show it in — its launcher icon is the manifest's.
38#[cfg(any(desktop, web, test))]
39pub(crate) const FRUS_ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
40
41/// What an application's icon is.
42#[derive(Clone, Debug, Default)]
43pub enum AppIcon {
44 /// The frus logo. What every application has until it chooses otherwise.
45 #[default]
46 Frus,
47 /// The developer's own, as PNG bytes — compiled in with `include_bytes!`.
48 Png(Cow<'static, [u8]>),
49 /// None of the framework's: the platform's own default (a plain window icon, the browser's
50 /// generic tab icon), or whatever the page declares.
51 None,
52}
53
54impl AppIcon {
55 /// The frus logo, the default.
56 pub fn frus() -> Self {
57 AppIcon::Frus
58 }
59
60 /// An icon of your own, from PNG bytes. Square, and at least 64 px a side is a good size;
61 /// a window on Windows draws up to 256.
62 pub fn from_png(png: impl Into<Cow<'static, [u8]>>) -> Self {
63 AppIcon::Png(png.into())
64 }
65
66 /// No icon from the framework.
67 pub fn none() -> Self {
68 AppIcon::None
69 }
70
71 /// The PNG to show, if there is one.
72 #[cfg(any(desktop, web, test))]
73 pub(crate) fn png(&self) -> Option<&[u8]> {
74 match self {
75 AppIcon::Frus => Some(FRUS_ICON_PNG),
76 AppIcon::Png(bytes) => Some(bytes),
77 AppIcon::None => Option::None,
78 }
79 }
80}
81
82/// Whether the browser tab's icon should be **replaced** by `icon`.
83///
84/// `declared` is the `href` of the icon the page already declares, if it does. The default
85/// yields to a page that has one — an empty `data:,` counts as *none*, the way a page silences
86/// the browser's request for `/favicon.ico` — but an icon the application names explicitly does
87/// not: it is the more specific choice.
88#[cfg(any(web, test))]
89pub(crate) fn replaces_the_pages_icon(icon: &AppIcon, declared: Option<&str>) -> bool {
90 let page_has_one = declared.is_some_and(|href| !href.is_empty() && href != "data:,");
91 match icon {
92 AppIcon::Frus => !page_has_one,
93 AppIcon::Png(_) => true,
94 AppIcon::None => false,
95 }
96}
97
98/// The window icon for winit, decoded from the PNG — or `None` when there is no icon or the
99/// bytes are not one. A bad icon is a line in the log and a window with the platform's default,
100/// never a panic: an icon is not worth the application.
101#[cfg(desktop)]
102pub(crate) fn window_icon(icon: &AppIcon) -> Option<winit::window::Icon> {
103 let png = icon.png()?;
104 let image = match frus_image::decode(png) {
105 Ok(image) => image,
106 Err(err) => {
107 log::warn!("the application's icon is not a PNG that can be read: {err}");
108 return Option::None;
109 }
110 };
111 match winit::window::Icon::from_rgba(image.rgba().to_vec(), image.width(), image.height()) {
112 Ok(icon) => Some(icon),
113 Err(err) => {
114 log::warn!("the application's icon cannot be used as a window icon: {err}");
115 Option::None
116 }
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 /// The default is the logo, at the size the window draws, with something in it and
125 /// something round it: a logo on a transparent background, not a blank square.
126 #[cfg(desktop)]
127 #[test]
128 fn the_default_icon_is_the_logo_at_256_pixels_with_a_transparent_background() {
129 let image = frus_image::decode(FRUS_ICON_PNG).expect("the embedded icon decodes");
130 assert_eq!((image.width(), image.height()), (256, 256));
131 // The alpha channel: every fourth byte of RGBA, from the fourth.
132 let alphas: Vec<u8> = image.rgba().iter().skip(3).step_by(4).copied().collect();
133 assert!(alphas.contains(&255), "a drawn mark");
134 assert!(alphas.contains(&0), "and a transparent corner");
135 assert!(
136 FRUS_ICON_PNG.len() < 64 * 1024,
137 "the icon is a few tens of kilobytes, not the artwork's {} kB",
138 FRUS_ICON_PNG.len() / 1024
139 );
140 }
141
142 #[test]
143 fn what_each_choice_shows() {
144 assert_eq!(AppIcon::default().png(), Some(FRUS_ICON_PNG));
145 assert_eq!(AppIcon::frus().png(), Some(FRUS_ICON_PNG));
146 assert_eq!(AppIcon::from_png(&b"png"[..]).png(), Some(&b"png"[..]));
147 assert_eq!(AppIcon::none().png(), Option::None);
148 }
149
150 /// An icon that cannot be read is no icon, and no panic.
151 #[cfg(desktop)]
152 #[test]
153 fn a_window_icon_is_made_for_a_png_and_only_for_a_png() {
154 assert!(window_icon(&AppIcon::frus()).is_some());
155 assert!(window_icon(&AppIcon::none()).is_none());
156 assert!(window_icon(&AppIcon::from_png(&b"not an image"[..])).is_none());
157 assert!(window_icon(&AppIcon::from_png(FRUS_ICON_PNG)).is_some());
158 }
159
160 /// The default yields to the page's own icon, and to nothing else; an icon the application
161 /// names does not yield; none touches nothing.
162 #[test]
163 fn the_default_yields_to_a_page_that_has_an_icon() {
164 let frus = AppIcon::frus();
165 assert!(
166 replaces_the_pages_icon(&frus, Option::None),
167 "no icon declared"
168 );
169 assert!(replaces_the_pages_icon(&frus, Some("")), "an empty one");
170 assert!(
171 replaces_the_pages_icon(&frus, Some("data:,")),
172 "the page's way of saying none"
173 );
174 assert!(
175 !replaces_the_pages_icon(&frus, Some("/favicon.png")),
176 "the page's own"
177 );
178 let own = AppIcon::from_png(&b"png"[..]);
179 assert!(
180 replaces_the_pages_icon(&own, Some("/favicon.png")),
181 "the application's own wins"
182 );
183 assert!(!replaces_the_pages_icon(&AppIcon::none(), Option::None));
184 assert!(!replaces_the_pages_icon(
185 &AppIcon::none(),
186 Some("/favicon.png")
187 ));
188 }
189}