Skip to content

Commit 2ab7ca6

Browse files
authored
feat: iOS masking for app icon previews (#161)
1 parent e1bb5e7 commit 2ab7ca6

3 files changed

Lines changed: 131 additions & 15 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/plumeimpactor/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ log.workspace = true
1818
chrono.workspace = true
1919
rustls.workspace = true
2020
image.workspace = true
21+
tiny-skia = "0.11.4"
2122
plume_core = { path = "../../crates/plume_core", features = ["tweaks"] }
2223
plume_utils = { path = "../../crates/plume_utils" }
2324
plume_store = { path = "../../crates/plume_store" }

apps/plumeimpactor/src/screen/package.rs

Lines changed: 128 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use iced::widget::{
44
use iced::{Alignment, Center, Element, Fill, Task};
55
use plume_utils::{Package, PlistInfoTrait, SignerInstallMode, SignerMode, SignerOptions};
66
use std::path::PathBuf;
7+
use tiny_skia::{FillRule, Mask, Path, PathBuilder, Transform};
78

89
use crate::appearance;
910

@@ -48,12 +49,12 @@ impl PackageScreen {
4849
let package_icon_handle = package
4950
.as_ref()
5051
.and_then(|p| p.app_icon_data.as_ref())
51-
.map(|data| image::Handle::from_bytes(data.clone()));
52+
.and_then(|data| icon_handle_from_bytes(data));
5253

5354
let custom_icon_path = options.custom_icon.clone();
5455
let custom_icon_handle = custom_icon_path
5556
.as_ref()
56-
.map(|path| image::Handle::from_path(path.clone()));
57+
.and_then(icon_handle_from_path);
5758

5859
Self {
5960
selected_package: package,
@@ -202,7 +203,7 @@ impl PackageScreen {
202203
if let Some(path) = path {
203204
self.options.custom_icon = Some(path.clone());
204205
self.custom_icon_path = Some(path.clone());
205-
self.custom_icon_handle = Some(image::Handle::from_path(path));
206+
self.custom_icon_handle = icon_handle_from_path(&path);
206207
}
207208

208209
Task::none()
@@ -449,22 +450,24 @@ impl PackageScreen {
449450
.align_y(Center);
450451

451452
let preview: Element<'_, Message> = if let Some(handle) = &self.custom_icon_handle {
452-
stack![
453+
container(stack![
453454
loading_indicator,
454-
image(handle.clone())
455-
.width(ICON_SIZE)
456-
.height(ICON_SIZE)
457-
.border_radius(appearance::THEME_CORNER_RADIUS)
458-
]
455+
image(handle.clone()).width(ICON_SIZE).height(ICON_SIZE)
456+
])
457+
.width(ICON_SIZE)
458+
.height(ICON_SIZE)
459+
.align_x(Center)
460+
.align_y(Center)
459461
.into()
460462
} else if let Some(handle) = &self.package_icon_handle {
461-
stack![
463+
container(stack![
462464
loading_indicator,
463-
image(handle.clone())
464-
.width(ICON_SIZE)
465-
.height(ICON_SIZE)
466-
.border_radius(appearance::THEME_CORNER_RADIUS)
467-
]
465+
image(handle.clone()).width(ICON_SIZE).height(ICON_SIZE)
466+
])
467+
.width(ICON_SIZE)
468+
.height(ICON_SIZE)
469+
.align_x(Center)
470+
.align_y(Center)
468471
.into()
469472
} else {
470473
container(text("No icon").size(11))
@@ -520,3 +523,113 @@ impl PackageScreen {
520523
}
521524
}
522525
}
526+
527+
const IOS_ICON_CORNER_RADIUS_FACTOR: f32 = 0.225;
528+
const IOS_ICON_EDGE: f32 = 1.528_665;
529+
const IOS_ICON_SHOULDER: f32 = 0.631_493_8;
530+
const IOS_ICON_KNEE: f32 = 0.074_911_39;
531+
const IOS_ICON_CTRL_EDGE: f32 = 1.088_493;
532+
const IOS_ICON_CTRL_SHOULDER: f32 = 0.868_406_95;
533+
const IOS_ICON_CTRL_CURVE_OUTER: f32 = 0.372_823_83;
534+
const IOS_ICON_CTRL_CURVE_INNER: f32 = 0.169_059_56;
535+
536+
fn icon_handle_from_bytes(data: &[u8]) -> Option<image::Handle> {
537+
icon_handle_from_image(::image::load_from_memory(data).ok()?)
538+
}
539+
540+
fn icon_handle_from_path(path: &PathBuf) -> Option<image::Handle> {
541+
icon_handle_from_image(::image::open(path).ok()?)
542+
}
543+
544+
fn icon_handle_from_image(image: ::image::DynamicImage) -> Option<image::Handle> {
545+
let mut pixels = image.to_rgba8();
546+
let (width, height) = pixels.dimensions();
547+
let mut mask = Mask::new(width, height)?;
548+
mask.fill_path(
549+
&ios_icon_mask(width as f32, height as f32)?,
550+
FillRule::Winding,
551+
true,
552+
Transform::identity(),
553+
);
554+
555+
for (pixel, coverage) in pixels.chunks_exact_mut(4).zip(mask.data()) {
556+
pixel[3] = ((u16::from(pixel[3]) * u16::from(*coverage) + 127) / 255) as u8;
557+
}
558+
559+
Some(image::Handle::from_rgba(width, height, pixels.into_raw()))
560+
}
561+
562+
fn ios_icon_mask(width: f32, height: f32) -> Option<Path> {
563+
let radius = width.min(height) * IOS_ICON_CORNER_RADIUS_FACTOR;
564+
let mut path = PathBuilder::new();
565+
let tl = |x: f32, y: f32| (x * radius, y * radius);
566+
let tr = |x: f32, y: f32| (width - x * radius, y * radius);
567+
let br = |x: f32, y: f32| (width - x * radius, height - y * radius);
568+
let bl = |x: f32, y: f32| (x * radius, height - y * radius);
569+
570+
let (x, y) = tl(IOS_ICON_EDGE, 0.0);
571+
path.move_to(x, y);
572+
573+
let (x, y) = tr(IOS_ICON_EDGE, 0.0);
574+
path.line_to(x, y);
575+
let (x1, y1) = tr(IOS_ICON_CTRL_EDGE, 0.0);
576+
let (x2, y2) = tr(IOS_ICON_CTRL_SHOULDER, 0.0);
577+
let (x, y) = tr(IOS_ICON_SHOULDER, IOS_ICON_KNEE);
578+
path.cubic_to(x1, y1, x2, y2, x, y);
579+
let (x1, y1) = tr(IOS_ICON_CTRL_CURVE_OUTER, IOS_ICON_CTRL_CURVE_INNER);
580+
let (x2, y2) = tr(IOS_ICON_CTRL_CURVE_INNER, IOS_ICON_CTRL_CURVE_OUTER);
581+
let (x, y) = tr(IOS_ICON_KNEE, IOS_ICON_SHOULDER);
582+
path.cubic_to(x1, y1, x2, y2, x, y);
583+
let (x1, y1) = tr(0.0, IOS_ICON_CTRL_SHOULDER);
584+
let (x2, y2) = tr(0.0, IOS_ICON_CTRL_EDGE);
585+
let (x, y) = tr(0.0, IOS_ICON_EDGE);
586+
path.cubic_to(x1, y1, x2, y2, x, y);
587+
588+
let (x, y) = br(0.0, IOS_ICON_EDGE);
589+
path.line_to(x, y);
590+
let (x1, y1) = br(0.0, IOS_ICON_CTRL_EDGE);
591+
let (x2, y2) = br(0.0, IOS_ICON_CTRL_SHOULDER);
592+
let (x, y) = br(IOS_ICON_KNEE, IOS_ICON_SHOULDER);
593+
path.cubic_to(x1, y1, x2, y2, x, y);
594+
let (x1, y1) = br(IOS_ICON_CTRL_CURVE_INNER, IOS_ICON_CTRL_CURVE_OUTER);
595+
let (x2, y2) = br(IOS_ICON_CTRL_CURVE_OUTER, IOS_ICON_CTRL_CURVE_INNER);
596+
let (x, y) = br(IOS_ICON_SHOULDER, IOS_ICON_KNEE);
597+
path.cubic_to(x1, y1, x2, y2, x, y);
598+
let (x1, y1) = br(IOS_ICON_CTRL_SHOULDER, 0.0);
599+
let (x2, y2) = br(IOS_ICON_CTRL_EDGE, 0.0);
600+
let (x, y) = br(IOS_ICON_EDGE, 0.0);
601+
path.cubic_to(x1, y1, x2, y2, x, y);
602+
603+
let (x, y) = bl(IOS_ICON_EDGE, 0.0);
604+
path.line_to(x, y);
605+
let (x1, y1) = bl(IOS_ICON_CTRL_EDGE, 0.0);
606+
let (x2, y2) = bl(IOS_ICON_CTRL_SHOULDER, 0.0);
607+
let (x, y) = bl(IOS_ICON_SHOULDER, IOS_ICON_KNEE);
608+
path.cubic_to(x1, y1, x2, y2, x, y);
609+
let (x1, y1) = bl(IOS_ICON_CTRL_CURVE_OUTER, IOS_ICON_CTRL_CURVE_INNER);
610+
let (x2, y2) = bl(IOS_ICON_CTRL_CURVE_INNER, IOS_ICON_CTRL_CURVE_OUTER);
611+
let (x, y) = bl(IOS_ICON_KNEE, IOS_ICON_SHOULDER);
612+
path.cubic_to(x1, y1, x2, y2, x, y);
613+
let (x1, y1) = bl(0.0, IOS_ICON_CTRL_SHOULDER);
614+
let (x2, y2) = bl(0.0, IOS_ICON_CTRL_EDGE);
615+
let (x, y) = bl(0.0, IOS_ICON_EDGE);
616+
path.cubic_to(x1, y1, x2, y2, x, y);
617+
618+
let (x, y) = tl(0.0, IOS_ICON_EDGE);
619+
path.line_to(x, y);
620+
let (x1, y1) = tl(0.0, IOS_ICON_CTRL_EDGE);
621+
let (x2, y2) = tl(0.0, IOS_ICON_CTRL_SHOULDER);
622+
let (x, y) = tl(IOS_ICON_KNEE, IOS_ICON_SHOULDER);
623+
path.cubic_to(x1, y1, x2, y2, x, y);
624+
let (x1, y1) = tl(IOS_ICON_CTRL_CURVE_INNER, IOS_ICON_CTRL_CURVE_OUTER);
625+
let (x2, y2) = tl(IOS_ICON_CTRL_CURVE_OUTER, IOS_ICON_CTRL_CURVE_INNER);
626+
let (x, y) = tl(IOS_ICON_SHOULDER, IOS_ICON_KNEE);
627+
path.cubic_to(x1, y1, x2, y2, x, y);
628+
let (x1, y1) = tl(IOS_ICON_CTRL_SHOULDER, 0.0);
629+
let (x2, y2) = tl(IOS_ICON_CTRL_EDGE, 0.0);
630+
let (x, y) = tl(IOS_ICON_EDGE, 0.0);
631+
path.cubic_to(x1, y1, x2, y2, x, y);
632+
633+
path.close();
634+
path.finish()
635+
}

0 commit comments

Comments
 (0)