neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Mask and size the flat launcher icon correctly
6ed9d8a · gmorell · 2026-09-25 21:20
Message
{commit_body(@commit)}
Files changed
modified
README.md
+7
−1
@@ -187,7 +187,13 @@ property of the machine, not of the icon, so it never travels in a document.
| 187 | 187 | |
| 188 | 188 | An export writes three things, because images alone are not enough: |
| 189 | 189 | |
| 190 | −1. the PNGs, across every density; | |
| 190 | +1. the PNGs, across every density. The flat `<name>.png` and the two adaptive | |
| 191 | + layers are **different sizes and different shapes**: the layers are 108dp | |
| 192 | + full-bleed squares for the launcher to mask itself, while the flat icon is | |
| 193 | + 48dp, cropped to the adaptive safe zone, and masked to a rounded square with | |
| 194 | + its corners cut away. A full-bleed square there is what makes an app show up | |
| 195 | + as a hard-edged tile in a launcher or an F-Droid index beside everyone | |
| 196 | + else's rounded ones; | |
| 191 | 197 | 2. `mipmap-anydpi-v26/<name>.xml`, the adaptive-icon descriptor. Without it |
| 192 | 198 | Android has no idea the background and foreground are related, falls back to |
| 193 | 199 | the flat PNG, and the layers are dead weight; |
modified
src/export.rs
+15
−2
@@ -6,6 +6,19 @@ use std::path::{Path, PathBuf};
| 6 | 6 | use crate::render; |
| 7 | 7 | use crate::spec::IconSpec; |
| 8 | 8 | |
| 9 | +/// Adaptive icon layers are 108dp square. | |
| 10 | +pub const ADAPTIVE_DP: u32 = 108; | |
| 11 | +/// The flat launcher icon is 48dp — a different size, not a different | |
| 12 | +/// rendering of the same one. Writing it at the adaptive size makes it 4.5x | |
| 13 | +/// larger than every other app's. | |
| 14 | +pub const LEGACY_DP: u32 = 48; | |
| 15 | + | |
| 16 | +/// The flat icon's pixel size for a given adaptive size. Exact at every | |
| 17 | +/// bucket: 108->48, 162->72, 216->96, 324->144, 432->192. | |
| 18 | +pub fn legacy_size(adaptive: u32) -> u32 { | |
| 19 | + adaptive * LEGACY_DP / ADAPTIVE_DP | |
| 20 | +} | |
| 21 | + | |
| 9 | 22 | /// Android adaptive icons are 108dp; these are the density buckets and the |
| 10 | 23 | /// pixel size each works out to. |
| 11 | 24 | pub const DENSITIES: &[(&str, u32)] = &[ |
@@ -52,7 +65,7 @@ pub fn export_mipmaps(
| 52 | 65 | for (bucket, size) in DENSITIES { |
| 53 | 66 | let dir = root.join(format!("mipmap-{bucket}")); |
| 54 | 67 | for (suffix, pm) in [ |
| 55 | − ("", render::render_composed(spec, *size)), | |
| 68 | + ("", render::render_legacy(spec, legacy_size(*size))), | |
| 56 | 69 | ("_background", render::render_background(spec, *size)), |
| 57 | 70 | ("_foreground", render::render_foreground(spec, *size)), |
| 58 | 71 | ] { |
@@ -97,7 +110,7 @@ pub fn export_zip(
| 97 | 110 | |
| 98 | 111 | for (bucket, size) in DENSITIES { |
| 99 | 112 | for (suffix, pm) in [ |
| 100 | − ("", render::render_composed(spec, *size)), | |
| 113 | + ("", render::render_legacy(spec, legacy_size(*size))), | |
| 101 | 114 | ("_background", render::render_background(spec, *size)), |
| 102 | 115 | ("_foreground", render::render_foreground(spec, *size)), |
| 103 | 116 | ] { |
modified
src/render.rs
+72
−0
@@ -422,6 +422,78 @@ pub fn render_foreground(spec: &IconSpec, size: u32) -> Pixmap {
| 422 | 422 | pm |
| 423 | 423 | } |
| 424 | 424 | |
| 425 | +/// Android's adaptive safe zone: of a 108dp canvas, launchers show the middle | |
| 426 | +/// 72dp. Everything outside it can be cropped away by the mask. | |
| 427 | +pub const SAFE_ZONE: f32 = 72.0 / 108.0; | |
| 428 | + | |
| 429 | +/// Corner radius of the flat launcher icon, as a fraction of its side. | |
| 430 | +/// | |
| 431 | +/// Measured from the shipped cbz icons rather than picked: a least-squares fit | |
| 432 | +/// of the alpha edge against a circular corner gives 52.3px on the 192px | |
| 433 | +/// xxxhdpi icon, and the lower densities agree once antialiasing is accounted | |
| 434 | +/// for. | |
| 435 | +pub const LEGACY_CORNER: f32 = 0.272; | |
| 436 | + | |
| 437 | +/// A rounded rectangle covering the whole pixmap, corners of radius `r`. | |
| 438 | +fn rounded_square(size: f32, r: f32) -> Option<Path> { | |
| 439 | + // Circle-approximating cubic control offset. | |
| 440 | + const K: f32 = 0.5522847; | |
| 441 | + let c = r * K; | |
| 442 | + let (lo, hi) = (0.0, size); | |
| 443 | + let mut pb = PathBuilder::new(); | |
| 444 | + pb.move_to(lo + r, lo); | |
| 445 | + pb.line_to(hi - r, lo); | |
| 446 | + pb.cubic_to(hi - r + c, lo, hi, lo + r - c, hi, lo + r); | |
| 447 | + pb.line_to(hi, hi - r); | |
| 448 | + pb.cubic_to(hi, hi - r + c, hi - r + c, hi, hi - r, hi); | |
| 449 | + pb.line_to(lo + r, hi); | |
| 450 | + pb.cubic_to(lo + r - c, hi, lo, hi - r + c, lo, hi - r); | |
| 451 | + pb.line_to(lo, lo + r); | |
| 452 | + pb.cubic_to(lo, lo + r - c, lo + r - c, lo, lo + r, lo); | |
| 453 | + pb.close(); | |
| 454 | + pb.finish() | |
| 455 | +} | |
| 456 | + | |
| 457 | +/// The flat `ic_icon.png` a launcher or a repository index shows when it is not | |
| 458 | +/// composing the adaptive layers itself. | |
| 459 | +/// | |
| 460 | +/// Two things distinguish it from [`render_composed`], and getting either | |
| 461 | +/// wrong is invisible until you look at a store listing: it is cropped to the | |
| 462 | +/// safe zone, so it frames the mark the way a launcher would, and its corners | |
| 463 | +/// are masked away. A full-bleed square here is what makes an icon show up as | |
| 464 | +/// a hard-edged tile among rounded ones. | |
| 465 | +pub fn render_legacy(spec: &IconSpec, size: u32) -> Pixmap { | |
| 466 | + let s = size as f32; | |
| 467 | + // Render the whole composition large enough that cropping to the safe zone | |
| 468 | + // lands on exactly `size` pixels. | |
| 469 | + let full = (s / SAFE_ZONE).round().max(s) as u32; | |
| 470 | + let composed = render_composed(spec, full); | |
| 471 | + let offset = (full as f32 - s) / 2.0; | |
| 472 | + | |
| 473 | + let mut out = Pixmap::new(size, size).expect("non-zero size"); | |
| 474 | + let mut paint = Paint { | |
| 475 | + anti_alias: true, | |
| 476 | + ..Default::default() | |
| 477 | + }; | |
| 478 | + paint.shader = Pattern::new( | |
| 479 | + composed.as_ref(), | |
| 480 | + SpreadMode::Pad, | |
| 481 | + tiny_skia::FilterQuality::Bicubic, | |
| 482 | + 1.0, | |
| 483 | + Transform::from_translate(-offset, -offset), | |
| 484 | + ); | |
| 485 | + if let Some(path) = rounded_square(s, s * LEGACY_CORNER) { | |
| 486 | + out.fill_path( | |
| 487 | + &path, | |
| 488 | + &paint, | |
| 489 | + FillRule::Winding, | |
| 490 | + Transform::identity(), | |
| 491 | + None, | |
| 492 | + ); | |
| 493 | + } | |
| 494 | + out | |
| 495 | +} | |
| 496 | + | |
| 425 | 497 | /// Composites foreground over background — the flattened icon. |
| 426 | 498 | pub fn render_composed(spec: &IconSpec, size: u32) -> Pixmap { |
| 427 | 499 | let mut bg = render_background(spec, size); |
modified
tests/reproduce.rs
+62
−3
@@ -163,11 +163,28 @@ fn export_writes_every_density_and_layer() {
| 163 | 163 | .join(format!("ic_icon{suffix}.png")); |
| 164 | 164 | assert!(p.exists(), "{} missing", p.display()); |
| 165 | 165 | let img = image::open(&p).expect("valid png"); |
| 166 | − assert_eq!(img.width(), *size, "{} wrong size", p.display()); | |
| 166 | + // The flat icon is 48dp; only the adaptive layers are 108dp. | |
| 167 | + let expected = if suffix.is_empty() { | |
| 168 | + icon_studio::export::legacy_size(*size) | |
| 169 | + } else { | |
| 170 | + *size | |
| 171 | + }; | |
| 172 | + assert_eq!(img.width(), expected, "{} wrong size", p.display()); | |
| 167 | 173 | } |
| 168 | 174 | } |
| 169 | − // xxxhdpi must land on 432px, the size the shipped cbz icons use. | |
| 170 | − assert!(dir.join("mipmap-xxxhdpi/ic_icon.png").exists()); | |
| 175 | + // The sizes the shipped cbz icons use at xxxhdpi. | |
| 176 | + assert_eq!( | |
| 177 | + image::open(dir.join("mipmap-xxxhdpi/ic_icon.png")) | |
| 178 | + .unwrap() | |
| 179 | + .width(), | |
| 180 | + 192 | |
| 181 | + ); | |
| 182 | + assert_eq!( | |
| 183 | + image::open(dir.join("mipmap-xxxhdpi/ic_icon_foreground.png")) | |
| 184 | + .unwrap() | |
| 185 | + .width(), | |
| 186 | + 432 | |
| 187 | + ); | |
| 171 | 188 | |
| 172 | 189 | // Without this Android ignores the layers and falls back to the flat PNG. |
| 173 | 190 | let xml = dir.join("mipmap-anydpi-v26/ic_icon.xml"); |
@@ -176,3 +193,45 @@ fn export_writes_every_density_and_layer() {
| 176 | 193 | assert!(body.contains(r#"@mipmap/ic_icon_foreground"#), "{body}"); |
| 177 | 194 | std::fs::remove_dir_all(&dir).ok(); |
| 178 | 195 | } |
| 196 | + | |
| 197 | +#[test] | |
| 198 | +fn the_flat_icon_is_masked_and_the_layers_are_not() { | |
| 199 | + // A full-bleed square here is what makes an icon show up as a hard-edged | |
| 200 | + // tile in a launcher or a repository index, next to everyone else's | |
| 201 | + // rounded ones. The adaptive layers must stay full-bleed, because the | |
| 202 | + // launcher applies its own mask to those. | |
| 203 | + let spec = casabeza_split(); | |
| 204 | + | |
| 205 | + let flat = render::render_legacy(&spec, 192); | |
| 206 | + let corner = flat.pixel(2, 2).unwrap(); | |
| 207 | + assert_eq!(corner.alpha(), 0, "flat icon corners should be cut away"); | |
| 208 | + let clear = flat.pixels().iter().filter(|p| p.alpha() == 0).count(); | |
| 209 | + let share = clear as f64 / (192.0 * 192.0); | |
| 210 | + assert!( | |
| 211 | + (0.05..0.08).contains(&share), | |
| 212 | + "corner radius looks wrong: {share:.3} of the icon is transparent" | |
| 213 | + ); | |
| 214 | + assert!( | |
| 215 | + flat.pixel(96, 96).unwrap().alpha() == 255, | |
| 216 | + "the middle must stay opaque" | |
| 217 | + ); | |
| 218 | + | |
| 219 | + for layer in [ | |
| 220 | + render::render_background(&spec, 432), | |
| 221 | + render::render_composed(&spec, 432), | |
| 222 | + ] { | |
| 223 | + assert_eq!( | |
| 224 | + layer.pixel(2, 2).unwrap().alpha(), | |
| 225 | + 255, | |
| 226 | + "adaptive layers are full-bleed" | |
| 227 | + ); | |
| 228 | + } | |
| 229 | +} | |
| 230 | + | |
| 231 | +#[test] | |
| 232 | +fn legacy_sizes_are_exact_at_every_density() { | |
| 233 | + use icon_studio::export::{legacy_size, DENSITIES}; | |
| 234 | + let got: Vec<u32> = DENSITIES.iter().map(|(_, a)| legacy_size(*a)).collect(); | |
| 235 | + // What the shipped cbz icons use. | |
| 236 | + assert_eq!(got, vec![48, 72, 96, 144, 192]); | |
| 237 | +} |
modified
tests/targets.rs
+3
−1
@@ -200,5 +200,7 @@ fn the_zip_bundle_carries_the_images_and_the_document() {
| 200 | 200 | let mut bytes = Vec::new(); |
| 201 | 201 | std::io::Read::read_to_end(&mut img, &mut bytes).unwrap(); |
| 202 | 202 | let decoded = image::load_from_memory(&bytes).expect("decodable png"); |
| 203 | − assert_eq!(decoded.width(), 432); | |
| 203 | + // The flat icon is 48dp, so 192px at xxxhdpi — not the 432px the adaptive | |
| 204 | + // layers use. | |
| 205 | + assert_eq!(decoded.width(), 192); | |
| 204 | 206 | } |
Parents: 7cbb63b