Mask and size the flat launcher icon correctly

6ed9d8a · gmorell · 2026-09-25 21:20

7 files +159 -7
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