Skip to main content

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}