feat(rust/gui-client): add "update ready" notification dot to the tray icon using a runtime compositor (#6432)
Also adds a "Download update" button in the bottom section of the tray menu when an update is ready. <img width="258" alt="image" src="https://github.com/user-attachments/assets/73d31ad2-5eb8-4cfd-9164-39fcad2ba031"> --------- Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
1
rust/Cargo.lock
generated
@@ -1892,6 +1892,7 @@ dependencies = [
|
||||
"native-dialog",
|
||||
"nix 0.29.0",
|
||||
"output_vt100",
|
||||
"png",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
|
||||
@@ -28,6 +28,7 @@ hex = "0.4.3"
|
||||
minidumper = "0.8.3"
|
||||
native-dialog = "0.7.0"
|
||||
output_vt100 = "0.1"
|
||||
png = "0.17.13" # `png` is free since we already need it for Tauri
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.12.5", default-features = false, features = ["stream", "rustls-tls"] }
|
||||
rustls = { workspace = true }
|
||||
|
||||
BIN
rust/gui-client/src-tauri/icons/tray/Busy layer.png
Normal file
|
After Width: | Height: | Size: 176 B |
|
Before Width: | Height: | Size: 1.2 KiB |
BIN
rust/gui-client/src-tauri/icons/tray/Logo grey.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
rust/gui-client/src-tauri/icons/tray/Signed out layer.png
Normal file
|
After Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 1.5 KiB |
BIN
rust/gui-client/src-tauri/icons/tray/Update ready layer.png
Normal file
|
After Width: | Height: | Size: 366 B |
@@ -315,23 +315,6 @@ async fn smoke_test(ctlr_tx: CtlrTx) -> Result<()> {
|
||||
.map_err(|s| anyhow!(s))
|
||||
.context("`ClearLogs` failed")?;
|
||||
|
||||
// Tray icon stress test
|
||||
let num_icon_cycles = 100;
|
||||
for _ in 0..num_icon_cycles {
|
||||
ctlr_tx
|
||||
.send(ControllerRequest::TestTrayIcon(system_tray::Icon::Busy))
|
||||
.await?;
|
||||
ctlr_tx
|
||||
.send(ControllerRequest::TestTrayIcon(system_tray::Icon::SignedIn))
|
||||
.await?;
|
||||
ctlr_tx
|
||||
.send(ControllerRequest::TestTrayIcon(
|
||||
system_tray::Icon::SignedOut,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
tracing::debug!(?num_icon_cycles, "Completed tray icon test");
|
||||
|
||||
// Give the app some time to export the zip and reach steady state
|
||||
tokio::time::sleep_until(quit_time).await;
|
||||
|
||||
@@ -443,8 +426,6 @@ pub(crate) enum ControllerRequest {
|
||||
SchemeRequest(SecretString),
|
||||
SignIn,
|
||||
SystemTrayMenu(TrayMenuEvent),
|
||||
/// Forces the tray icon to a specific icon to stress-test the tray code
|
||||
TestTrayIcon(system_tray::Icon),
|
||||
UpdateAvailable(crate::client::updates::Release),
|
||||
UpdateNotificationClicked(Url),
|
||||
}
|
||||
@@ -503,6 +484,8 @@ struct Controller {
|
||||
log_filter_reloader: LogFilterReloader,
|
||||
status: Status,
|
||||
tray: system_tray::Tray,
|
||||
/// URL of a release that's ready to download
|
||||
update_url: Option<Url>,
|
||||
uptime: client::uptime::Tracker,
|
||||
}
|
||||
|
||||
@@ -678,14 +661,14 @@ impl Controller {
|
||||
Req::SystemTrayMenu(TrayMenuEvent::Quit) => Err(anyhow!(
|
||||
"Impossible error: `Quit` should be handled before this"
|
||||
))?,
|
||||
Req::TestTrayIcon(icon) => self.tray.set_icon(icon)?,
|
||||
Req::UpdateAvailable(release) => {
|
||||
let title = format!("Firezone {} available for download", release.version);
|
||||
|
||||
// We don't need to route through the controller here either, we could
|
||||
// use the `open` crate directly instead of Tauri's wrapper
|
||||
// `tauri::api::shell::open`
|
||||
os::show_update_notification(self.ctlr_tx.clone(), &title, release.download_url)?;
|
||||
os::show_update_notification(self.ctlr_tx.clone(), &title, release.download_url.clone())?;
|
||||
self.update_url = Some(release.download_url);
|
||||
}
|
||||
Req::UpdateNotificationClicked(download_url) => {
|
||||
tracing::info!("UpdateNotificationClicked in run_controller!");
|
||||
@@ -743,7 +726,7 @@ impl Controller {
|
||||
}
|
||||
IpcServerMsg::TerminatingGracefully => {
|
||||
tracing::info!("Caught TerminatingGracefully");
|
||||
self.tray.set_icon(system_tray::Icon::SignedOut).ok();
|
||||
self.tray.set_icon(system_tray::Icon::terminating()).ok();
|
||||
Err(Error::IpcServiceTerminating)
|
||||
}
|
||||
IpcServerMsg::TunnelReady => {
|
||||
@@ -851,31 +834,34 @@ impl Controller {
|
||||
fn refresh_system_tray_menu(&mut self) -> Result<()> {
|
||||
// TODO: Refactor `Controller` and the auth module so that "Are we logged in?"
|
||||
// doesn't require such complicated control flow to answer.
|
||||
let menu = if let Some(auth_session) = self.auth.session() {
|
||||
let connlib = if let Some(auth_session) = self.auth.session() {
|
||||
match &self.status {
|
||||
Status::Disconnected => {
|
||||
tracing::error!("We have an auth session but no connlib session");
|
||||
system_tray::AppState::SignedOut
|
||||
system_tray::ConnlibState::SignedOut
|
||||
}
|
||||
Status::RetryingConnection { .. } => system_tray::AppState::RetryingConnection,
|
||||
Status::RetryingConnection { .. } => system_tray::ConnlibState::RetryingConnection,
|
||||
Status::TunnelReady { resources } => {
|
||||
system_tray::AppState::SignedIn(system_tray::SignedIn {
|
||||
system_tray::ConnlibState::SignedIn(system_tray::SignedIn {
|
||||
actor_name: &auth_session.actor_name,
|
||||
favorite_resources: &self.advanced_settings.favorite_resources,
|
||||
disabled_resources: &self.advanced_settings.disabled_resources,
|
||||
resources,
|
||||
})
|
||||
}
|
||||
Status::WaitingForPortal { .. } => system_tray::AppState::WaitingForPortal,
|
||||
Status::WaitingForTunnel { .. } => system_tray::AppState::WaitingForTunnel,
|
||||
Status::WaitingForPortal { .. } => system_tray::ConnlibState::WaitingForPortal,
|
||||
Status::WaitingForTunnel { .. } => system_tray::ConnlibState::WaitingForTunnel,
|
||||
}
|
||||
} else if self.auth.ongoing_request().is_ok() {
|
||||
// Signing in, waiting on deep link callback
|
||||
system_tray::AppState::WaitingForBrowser
|
||||
system_tray::ConnlibState::WaitingForBrowser
|
||||
} else {
|
||||
system_tray::AppState::SignedOut
|
||||
system_tray::ConnlibState::SignedOut
|
||||
};
|
||||
self.tray.update(menu)?;
|
||||
self.tray.update(system_tray::AppState {
|
||||
connlib,
|
||||
update_url: self.update_url.clone(),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -947,6 +933,7 @@ async fn run_controller(
|
||||
log_filter_reloader,
|
||||
status: Default::default(),
|
||||
tray,
|
||||
update_url: None,
|
||||
uptime: Default::default(),
|
||||
};
|
||||
|
||||
|
||||
@@ -12,16 +12,21 @@ use connlib_shared::{
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use tauri::{SystemTray, SystemTrayHandle};
|
||||
use url::Url;
|
||||
|
||||
mod builder;
|
||||
pub(crate) mod compositor;
|
||||
|
||||
pub(crate) use builder::{item, Event, Menu, Window};
|
||||
|
||||
// Figma is the source of truth for the tray icons
|
||||
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=OGFabKWPx7PRUZmq-0>
|
||||
const BUSY_ICON: &[u8] = include_bytes!("../../../icons/tray/Busy.png");
|
||||
const SIGNED_IN_ICON: &[u8] = include_bytes!("../../../icons/tray/Signed in.png");
|
||||
const SIGNED_OUT_ICON: &[u8] = include_bytes!("../../../icons/tray/Signed out.png");
|
||||
// Figma is the source of truth for the tray icon layers
|
||||
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=nHBOzOnSY5Ol4asV-0>
|
||||
const LOGO_BASE: &[u8] = include_bytes!("../../../icons/tray/Logo.png");
|
||||
const LOGO_GREY_BASE: &[u8] = include_bytes!("../../../icons/tray/Logo grey.png");
|
||||
const BUSY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Busy layer.png");
|
||||
const SIGNED_OUT_LAYER: &[u8] = include_bytes!("../../../icons/tray/Signed out layer.png");
|
||||
const UPDATE_READY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Update ready layer.png");
|
||||
|
||||
const TOOLTIP: &str = "Firezone";
|
||||
const QUIT_TEXT_SIGNED_OUT: &str = "Quit Firezone";
|
||||
|
||||
@@ -42,9 +47,13 @@ const ENABLE: &str = "Enable this resource";
|
||||
pub(crate) const INTERNET_RESOURCE_DESCRIPTION: &str = "All network traffic";
|
||||
|
||||
pub(crate) fn loading() -> SystemTray {
|
||||
let state = AppState {
|
||||
connlib: ConnlibState::Loading,
|
||||
update_url: None,
|
||||
};
|
||||
SystemTray::new()
|
||||
.with_icon(tauri::Icon::Raw(BUSY_ICON.into()))
|
||||
.with_menu(AppState::Loading.build())
|
||||
.with_icon(Icon::default().tauri_icon())
|
||||
.with_menu(state.build())
|
||||
.with_tooltip(TOOLTIP)
|
||||
}
|
||||
|
||||
@@ -53,7 +62,12 @@ pub(crate) struct Tray {
|
||||
last_icon_set: Icon,
|
||||
}
|
||||
|
||||
pub(crate) enum AppState<'a> {
|
||||
pub(crate) struct AppState<'a> {
|
||||
pub(crate) connlib: ConnlibState<'a>,
|
||||
pub(crate) update_url: Option<Url>,
|
||||
}
|
||||
|
||||
pub(crate) enum ConnlibState<'a> {
|
||||
Loading,
|
||||
RetryingConnection,
|
||||
SignedIn(SignedIn<'a>),
|
||||
@@ -125,7 +139,13 @@ impl<'a> SignedIn<'a> {
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(crate) enum Icon {
|
||||
pub(crate) struct Icon {
|
||||
base: IconBase,
|
||||
update_ready: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum IconBase {
|
||||
/// Must be equivalent to the default app icon, since we assume this is set when we start
|
||||
Busy,
|
||||
SignedIn,
|
||||
@@ -134,7 +154,34 @@ pub(crate) enum Icon {
|
||||
|
||||
impl Default for Icon {
|
||||
fn default() -> Self {
|
||||
Self::Busy
|
||||
Self {
|
||||
base: IconBase::Busy,
|
||||
update_ready: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
fn tauri_icon(&self) -> tauri::Icon {
|
||||
let layers = match self.base {
|
||||
IconBase::Busy => &[LOGO_GREY_BASE, BUSY_LAYER][..],
|
||||
IconBase::SignedIn => &[LOGO_BASE][..],
|
||||
IconBase::SignedOut => &[LOGO_GREY_BASE, SIGNED_OUT_LAYER][..],
|
||||
}
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(self.update_ready.then_some(UPDATE_READY_LAYER));
|
||||
let composed = compositor::compose(layers)
|
||||
.expect("PNG decoding should always succeed for baked-in PNGs");
|
||||
composed.into()
|
||||
}
|
||||
|
||||
/// Generic icon for unusual terminating cases like if the IPC service stops running
|
||||
pub(crate) fn terminating() -> Self {
|
||||
Self {
|
||||
base: IconBase::SignedOut,
|
||||
update_ready: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,13 +194,18 @@ impl Tray {
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, state: AppState) -> Result<()> {
|
||||
let new_icon = match &state {
|
||||
AppState::Loading
|
||||
| AppState::RetryingConnection
|
||||
| AppState::SignedOut
|
||||
| AppState::WaitingForBrowser => Icon::SignedOut,
|
||||
AppState::SignedIn { .. } => Icon::SignedIn,
|
||||
AppState::WaitingForPortal | AppState::WaitingForTunnel => Icon::Busy,
|
||||
let base = match &state.connlib {
|
||||
ConnlibState::Loading
|
||||
| ConnlibState::RetryingConnection
|
||||
| ConnlibState::WaitingForBrowser
|
||||
| ConnlibState::WaitingForPortal
|
||||
| ConnlibState::WaitingForTunnel => IconBase::Busy,
|
||||
ConnlibState::SignedOut => IconBase::SignedOut,
|
||||
ConnlibState::SignedIn { .. } => IconBase::SignedIn,
|
||||
};
|
||||
let new_icon = Icon {
|
||||
base,
|
||||
update_ready: state.update_url.is_some(),
|
||||
};
|
||||
|
||||
self.handle.set_tooltip(TOOLTIP)?;
|
||||
@@ -163,7 +215,8 @@ impl Tray {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Normally only needed for the stress test
|
||||
// Only needed for the stress test
|
||||
// Otherwise it would be inlined
|
||||
pub(crate) fn set_icon(&mut self, icon: Icon) -> Result<()> {
|
||||
if icon != self.last_icon_set {
|
||||
// Don't call `set_icon` too often. On Linux it writes a PNG to `/run/user/$UID/tao/tray-icon-*.png` every single time.
|
||||
@@ -177,34 +230,31 @@ impl Tray {
|
||||
}
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
fn tauri_icon(&self) -> tauri::Icon {
|
||||
let bytes = match self {
|
||||
Self::Busy => BUSY_ICON,
|
||||
Self::SignedIn => SIGNED_IN_ICON,
|
||||
Self::SignedOut => SIGNED_OUT_ICON,
|
||||
};
|
||||
tauri::Icon::Raw(bytes.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> AppState<'a> {
|
||||
fn build(self) -> tauri::SystemTrayMenu {
|
||||
self.into_menu().build()
|
||||
}
|
||||
|
||||
fn into_menu(self) -> Menu {
|
||||
match self {
|
||||
Self::Loading => Menu::default().disabled("Loading..."),
|
||||
Self::RetryingConnection => retrying_sign_in("Waiting for Internet access..."),
|
||||
Self::SignedIn(x) => signed_in(&x),
|
||||
Self::SignedOut => Menu::default()
|
||||
.item(Event::SignIn, "Sign In")
|
||||
.add_bottom_section(QUIT_TEXT_SIGNED_OUT),
|
||||
Self::WaitingForBrowser => signing_in("Waiting for browser..."),
|
||||
Self::WaitingForPortal => signing_in("Connecting to Firezone Portal..."),
|
||||
Self::WaitingForTunnel => signing_in("Raising tunnel..."),
|
||||
}
|
||||
let quit_text = match &self.connlib {
|
||||
ConnlibState::Loading
|
||||
| ConnlibState::RetryingConnection
|
||||
| ConnlibState::SignedOut
|
||||
| ConnlibState::WaitingForBrowser
|
||||
| ConnlibState::WaitingForPortal
|
||||
| ConnlibState::WaitingForTunnel => QUIT_TEXT_SIGNED_OUT,
|
||||
ConnlibState::SignedIn(_) => DISCONNECT_AND_QUIT,
|
||||
};
|
||||
let menu = match self.connlib {
|
||||
ConnlibState::Loading => Menu::default().disabled("Loading..."),
|
||||
ConnlibState::RetryingConnection => retrying_sign_in("Waiting for Internet access..."),
|
||||
ConnlibState::SignedIn(x) => signed_in(&x),
|
||||
ConnlibState::SignedOut => Menu::default().item(Event::SignIn, "Sign In"),
|
||||
ConnlibState::WaitingForBrowser => signing_in("Waiting for browser..."),
|
||||
ConnlibState::WaitingForPortal => signing_in("Connecting to Firezone Portal..."),
|
||||
ConnlibState::WaitingForTunnel => signing_in("Raising tunnel..."),
|
||||
};
|
||||
menu.add_bottom_section(self.update_url, quit_text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +311,7 @@ fn signed_in(signed_in: &SignedIn) -> Menu {
|
||||
menu = menu.separator().add_submenu(OTHER_RESOURCES, submenu);
|
||||
}
|
||||
|
||||
menu.add_bottom_section(DISCONNECT_AND_QUIT)
|
||||
menu
|
||||
}
|
||||
|
||||
fn retrying_sign_in(waiting_message: &str) -> Menu {
|
||||
@@ -269,14 +319,12 @@ fn retrying_sign_in(waiting_message: &str) -> Menu {
|
||||
.disabled(waiting_message)
|
||||
.item(Event::RetryPortalConnection, "Retry sign-in")
|
||||
.item(Event::CancelSignIn, "Cancel sign-in")
|
||||
.add_bottom_section(QUIT_TEXT_SIGNED_OUT)
|
||||
}
|
||||
|
||||
fn signing_in(waiting_message: &str) -> Menu {
|
||||
Menu::default()
|
||||
.disabled(waiting_message)
|
||||
.item(Event::CancelSignIn, "Cancel sign-in")
|
||||
.add_bottom_section(QUIT_TEXT_SIGNED_OUT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -301,12 +349,15 @@ mod tests {
|
||||
favorite_resources: &'a HashSet<ResourceId>,
|
||||
disabled_resources: &'a HashSet<ResourceId>,
|
||||
) -> AppState<'a> {
|
||||
AppState::SignedIn(SignedIn {
|
||||
actor_name: "Jane Doe",
|
||||
favorite_resources,
|
||||
resources,
|
||||
disabled_resources,
|
||||
})
|
||||
AppState {
|
||||
connlib: ConnlibState::SignedIn(SignedIn {
|
||||
actor_name: "Jane Doe",
|
||||
favorite_resources,
|
||||
resources,
|
||||
disabled_resources,
|
||||
}),
|
||||
update_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resources() -> Vec<ResourceDescription> {
|
||||
@@ -357,7 +408,7 @@ mod tests {
|
||||
.item(Event::SignOut, SIGN_OUT)
|
||||
.separator()
|
||||
.disabled(RESOURCES)
|
||||
.add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
@@ -379,7 +430,7 @@ mod tests {
|
||||
.item(Event::SignOut, SIGN_OUT)
|
||||
.separator()
|
||||
.disabled(RESOURCES)
|
||||
.add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
@@ -451,7 +502,7 @@ mod tests {
|
||||
.copyable("test")
|
||||
.copyable(ALL_GATEWAYS_OFFLINE),
|
||||
)
|
||||
.add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
assert_eq!(
|
||||
actual,
|
||||
expected,
|
||||
@@ -528,7 +579,7 @@ mod tests {
|
||||
.copyable(NO_ACTIVITY),
|
||||
),
|
||||
)
|
||||
.add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
@@ -604,7 +655,7 @@ mod tests {
|
||||
.copyable("test")
|
||||
.copyable(ALL_GATEWAYS_OFFLINE),
|
||||
)
|
||||
.add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
|
||||
@@ -97,9 +97,13 @@ fn resource_header(res: &ResourceDescription) -> Item {
|
||||
|
||||
impl Menu {
|
||||
/// Appends things that always show, like About, Settings, Help, Quit, etc.
|
||||
pub(crate) fn add_bottom_section(self, quit_text: &str) -> Self {
|
||||
self.separator()
|
||||
.item(Event::ShowWindow(Window::About), "About Firezone")
|
||||
pub(crate) fn add_bottom_section(mut self, update_url: Option<Url>, quit_text: &str) -> Self {
|
||||
self = self.separator();
|
||||
if let Some(url) = update_url {
|
||||
self = self.item(Event::Url(url), "Download update...")
|
||||
}
|
||||
|
||||
self.item(Event::ShowWindow(Window::About), "About Firezone")
|
||||
.item(Event::AdminPortal, "Admin Portal...")
|
||||
.add_submenu(
|
||||
"Help",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! A minimal graphics compositor for the little 32x32 tray icons
|
||||
//!
|
||||
//! It's common for web apps like Discord, Element, and Slack to composite
|
||||
//! their favicons in an offscreen HTML5 canvas, so that they can layer
|
||||
//! notification dots and other stuff over the logo, without maintaining
|
||||
//! multiple nearly-identical graphics assets.
|
||||
//!
|
||||
//! Using the HTML5 canvas in Tauri would make us depend on it too much,
|
||||
//! and the math for compositing RGBA images in a mostly-gamma-correct way
|
||||
//! is simple enough to just replicate it here.
|
||||
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
|
||||
pub(crate) struct Image {
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl From<Image> for tauri::Icon {
|
||||
fn from(val: Image) -> Self {
|
||||
Self::Rgba {
|
||||
rgba: val.rgba,
|
||||
width: val.width,
|
||||
height: val.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds up an image via painter's algorithm
|
||||
///
|
||||
/// <https://en.wikipedia.org/wiki/Painter%27s_algorithm>
|
||||
///
|
||||
/// # Args
|
||||
///
|
||||
/// - `layers` - An iterator of PNG-compressed layers. We assume alpha is NOT pre-multiplied because Figma doesn't seem to export pre-multiplied images.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An `Image` with the same dimensions as the first layer.
|
||||
|
||||
pub(crate) fn compose<'a, I: IntoIterator<Item = &'a [u8]>>(layers: I) -> Result<Image> {
|
||||
let mut dst = None;
|
||||
|
||||
for layer in layers {
|
||||
// Decode the metadata for this PNG layer
|
||||
let decoder = png::Decoder::new(layer);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let info = reader.info();
|
||||
|
||||
// Create the output buffer if needed
|
||||
let dst = dst.get_or_insert_with(|| Image {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
rgba: vec![0; reader.output_buffer_size()],
|
||||
});
|
||||
|
||||
ensure!(info.width == dst.width);
|
||||
ensure!(info.height == dst.height);
|
||||
|
||||
// Decompress the PNG layer
|
||||
let mut rgba = vec![0; dst.rgba.len()];
|
||||
let info = reader.next_frame(&mut rgba)?;
|
||||
ensure!(info.buffer_size() == rgba.len());
|
||||
|
||||
// Do the actual composite
|
||||
// Do all the math with floats so it's easier to write and read the code
|
||||
let gamma = 2.2;
|
||||
let inv_gamma = 1.0 / gamma;
|
||||
|
||||
for (src, dst) in rgba.chunks_exact(4).zip(dst.rgba.chunks_exact_mut(4)) {
|
||||
let src_a = src[3] as f32 / 255.0;
|
||||
|
||||
for (src_int, dst_int) in (src[0..3]).iter().zip(&mut dst[0..3]) {
|
||||
let src_c = *src_int as f32 / 255.0;
|
||||
let dst_c = *dst_int as f32 / 255.0;
|
||||
|
||||
// Convert from gamma to linear space
|
||||
let src_c = src_c.powf(gamma);
|
||||
let dst_c = dst_c.powf(gamma);
|
||||
|
||||
// Linear interp between the src and dst colors depending on the src alpha
|
||||
let dst_c = src_c * src_a + dst_c * (1.0 - src_a);
|
||||
|
||||
// Convert back to gamma space and clamp / saturate
|
||||
let dst_c = dst_c.powf(inv_gamma).clamp(0.0, 1.0);
|
||||
|
||||
*dst_int = (dst_c * 255.0) as u8;
|
||||
}
|
||||
|
||||
// Add the source alpha into the dest alpha
|
||||
dst[3] = dst[3].saturating_add(src[3]);
|
||||
}
|
||||
}
|
||||
|
||||
dst.context("No layers")
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
"csp": null
|
||||
},
|
||||
"systemTray": {
|
||||
"iconPath": "icons/tray/Busy.png",
|
||||
"iconPath": "icons/tray/Busy layer.png",
|
||||
"iconAsTemplate": true
|
||||
},
|
||||
"windows": [
|
||||
|
||||
@@ -16,6 +16,9 @@ export default function GUI({ title }: { title: string }) {
|
||||
{/*
|
||||
<Entry version="1.2.2" date={new Date(todo)}>
|
||||
<ul className="list-disc space-y-2 pl-4 mb-4">
|
||||
<ChangeItem pull="6432">
|
||||
Shows an orange dot on the tray icon when an update is ready to download.
|
||||
</ChangeItem>
|
||||
</ul>
|
||||
</Entry>
|
||||
*/}
|
||||
|
||||