feat: render the tray badge as straight RGBA for Tauri tray icons

This commit is contained in:
tiennm99 committed 2026-10-21 13:45:49 +07:00
1 parent a395c66248
commit 539f26019b
5 files changed
+229 -373

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
//! Anti-aliased tray badge rendering.
//!
//! A filled disk plus a percentage-sweep ring, drawn with tiny-skia. The
//! badge is intentionally text-free: the floating bubble already shows the
//! exact percentage, the tray badge is a coarse colour-and-fill indicator.
use tiny_skia::{FillRule, LineCap, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
use crate::core::usage_color;
use crate::usage::ProviderId;
/// Width and height of the badge in pixels.
pub const BADGE_PX: u32 = 32;
/// Colour of the unused part of the ring.
const TRACK_RGB: [u8; 3] = [0x3a, 0x3a, 0x3a];
/// Render the badge for `kind` with `percent` of the ring filled, or only
/// the track when `percent` is `None`. Premultiplied RGBA.
pub fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32x32 pixmap");
pixmap.fill(tiny_skia::Color::TRANSPARENT);
let c = BADGE_PX as f32 / 2.0;
let outer = c - 1.0;
let inner = outer * 0.62;
let ring_radius = (inner + outer) / 2.0;
let ring_width = outer - inner - 1.0;
// Filled inner disk in the provider's base tint.
let mut pb = PathBuilder::new();
pb.push_circle(c, c, inner);
if let Some(path) = pb.finish() {
pixmap.fill_path(
&path,
&paint(base_color(kind)),
FillRule::Winding,
Transform::identity(),
None,
);
}
// Track ring (the unused portion of the quota).
let mut pb = PathBuilder::new();
pb.push_circle(c, c, ring_radius);
if let Some(path) = pb.finish() {
let stroke = Stroke {
width: ring_width,
..Stroke::default()
};
pixmap.stroke_path(
&path,
&paint(TRACK_RGB),
&stroke,
Transform::identity(),
None,
);
}
// Active sweep: the used share of the ring in the usage colour.
if let Some(p) = percent {
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
if sweep > 0.0 {
if let Some(path) = build_arc(c, c, ring_radius, sweep) {
let stroke = Stroke {
width: ring_width,
line_cap: LineCap::Round,
..Stroke::default()
};
pixmap.stroke_path(
&path,
&paint(usage_rgb(kind, p)),
&stroke,
Transform::identity(),
None,
);
}
}
}
pixmap
}
/// The pixmap as straight (non-premultiplied) RGBA bytes, the format
/// `tauri::image::Image::new_owned` expects.
pub fn demultiplied_rgba(pixmap: &Pixmap) -> Vec<u8> {
pixmap
.pixels()
.iter()
.flat_map(|p| {
let c = p.demultiply();
[c.red(), c.green(), c.blue(), c.alpha()]
})
.collect()
}
fn paint(rgb: [u8; 3]) -> Paint<'static> {
let mut paint = Paint::default();
paint.set_color_rgba8(rgb[0], rgb[1], rgb[2], 255);
paint.anti_alias = true;
paint
}
/// An open polyline arc from 12 o'clock, clockwise. tiny-skia 0.11 has no
/// arc primitive, so the circumference is sampled.
fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<Path> {
let segments = ((sweep_fraction * 64.0).ceil() as usize).max(1);
let start = -std::f32::consts::FRAC_PI_2;
let total = sweep_fraction * std::f32::consts::TAU;
let mut pb = PathBuilder::new();
for i in 0..=segments {
let a = start + (i as f32 / segments as f32) * total;
let (x, y) = (cx + a.cos() * radius, cy + a.sin() * radius);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
pb.finish()
}
fn base_color(kind: ProviderId) -> [u8; 3] {
match kind {
// A dark warm tint reads as Claude without copying its exact accent.
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
// Cool dark slate for Codex.
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
// Deep green for OpenCode Go.
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
}
}
/// Sweep colour. The badge disk is always dark whatever the system theme,
/// so the dark palette keeps the ring readable (Codex stays white).
fn usage_rgb(kind: ProviderId, percent: f64) -> [u8; 3] {
let c = usage_color::bar_fill_color(kind, true, percent);
[c.r, c.g, c.b]
}
#[cfg(test)]
mod tests {
use super::*;
/// Straight RGB at a pixel, asserting it is fully opaque.
fn opaque_rgb(pixmap: &Pixmap, x: u32, y: u32) -> [u8; 3] {
let p = pixmap.pixel(x, y).expect("pixel in range").demultiply();
assert_eq!(p.alpha(), 255, "pixel ({x},{y}) is not opaque");
[p.red(), p.green(), p.blue()]
}
/// The pixel whose centre lies on the ring radius (12.15) to the right
/// of the centre: x = 16 + 12.15 rounds into pixel 28.
const TRACK_SAMPLE: (u32, u32) = (28, 16);
/// The pixel at 12 o'clock on the ring radius: y = 16 - 12.15 = 3.85.
const TOP_SAMPLE: (u32, u32) = (16, 3);
#[test]
fn centre_is_the_provider_disk() {
for kind in ProviderId::ALL {
let pixmap = render_pixmap(kind, Some(50.0));
assert_eq!(opaque_rgb(&pixmap, 16, 16), base_color(kind), "{kind:?}");
}
}
#[test]
fn ring_is_track_coloured_without_a_percent() {
let pixmap = render_pixmap(ProviderId::Claude, None);
assert_eq!(
opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1),
TRACK_RGB
);
assert_eq!(opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB);
// Zero percent draws no arc either.
let zero = render_pixmap(ProviderId::Claude, Some(0.0));
assert_eq!(opaque_rgb(&zero, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB);
}
#[test]
fn arc_starts_at_twelve_o_clock_for_any_percent() {
for kind in ProviderId::LIVE_USAGE {
for pct in [0.5, 1.0, 25.0, 59.9, 60.0, 80.0, 95.0, 100.0, 150.0] {
let pixmap = render_pixmap(kind, Some(pct));
assert_eq!(
opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1),
usage_rgb(kind, pct),
"{kind:?} at {pct}%"
);
}
}
}
#[test]
fn a_quarter_sweep_leaves_the_left_side_as_track() {
let pixmap = render_pixmap(ProviderId::Claude, Some(25.0));
// 3 o'clock is the end of the sweep (round cap), 9 o'clock is track.
assert_eq!(
opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1),
usage_rgb(ProviderId::Claude, 25.0)
);
assert_eq!(opaque_rgb(&pixmap, 3, 16), TRACK_RGB);
}
#[test]
fn corners_stay_transparent() {
let pixmap = render_pixmap(ProviderId::ChatGpt, Some(100.0));
assert_eq!(pixmap.pixel(0, 0).expect("pixel").alpha(), 0);
assert_eq!(pixmap.pixel(31, 31).expect("pixel").alpha(), 0);
}
#[test]
fn demultiply_restores_straight_alpha() {
let mut pixmap = Pixmap::new(2, 1).expect("pixmap");
// 50% alpha red: premultiplied (128, 0, 0, 128).
pixmap.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 128));
assert_eq!(pixmap.data()[..4], [128, 0, 0, 128]);
assert_eq!(demultiplied_rgba(&pixmap), [255, 0, 0, 128, 255, 0, 0, 128]);
let badge = render_pixmap(ProviderId::Claude, Some(42.0));
let rgba = demultiplied_rgba(&badge);
assert_eq!(rgba.len(), (BADGE_PX * BADGE_PX * 4) as usize);
// Opaque pixels are unchanged; transparent ones stay zero.
assert_eq!(rgba[..4], [0, 0, 0, 0]);
let centre = ((16 * BADGE_PX + 16) * 4) as usize;
assert_eq!(rgba[centre..centre + 3], base_color(ProviderId::Claude));
assert_eq!(rgba[centre + 3], 255);
}
}
+1
View File
@@ -3,6 +3,7 @@
//! The binary in `src/main.rs` only calls [`run`]. Every module is `pub` so
//! that items wired up incrementally do not trip `dead_code`.
pub mod badge;
pub mod cli;
pub mod commands;
pub mod core;
-208
View File
@@ -1,208 +0,0 @@
// Anti-aliased tray badge rendering.
//
// We draw a filled circle + percentage-sweep ring with tiny-skia, then
// hand the resulting BGRA pixmap to Win32 as an HICON via
// `CreateIconIndirect`. The badge is intentionally text-free — the
// floating bubble already shows the exact percentage; the tray badge
// is just a coarse colour-and-fill indicator.
use std::ffi::c_void;
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
use windows::Win32::Foundation::{BOOL, HWND};
use windows::Win32::Graphics::Gdi::{
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
DIB_RGB_COLORS, HBITMAP,
};
use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, HICON, ICONINFO};
use crate::usage::ProviderId;
const BADGE_PX: u32 = 32;
pub fn render_hicon(kind: ProviderId, percent: Option<f64>) -> HICON {
let pixmap = render_pixmap(kind, percent);
pixmap_to_hicon(&pixmap).unwrap_or_default()
}
fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32×32 pixmap");
pixmap.fill(tiny_skia::Color::TRANSPARENT);
let cx = BADGE_PX as f32 / 2.0;
let cy = BADGE_PX as f32 / 2.0;
let outer = (BADGE_PX as f32 / 2.0) - 1.0;
let inner = outer * 0.62;
// Filled inner disk in the model's base tint.
let base = base_color(kind);
{
let mut paint = Paint::default();
paint.set_color_rgba8(base[0], base[1], base[2], 255);
paint.anti_alias = true;
let mut pb = PathBuilder::new();
pb.push_circle(cx, cy, inner);
if let Some(path) = pb.finish() {
pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
// Track ring (the unused portion of the quota).
{
let mut paint = Paint::default();
paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255);
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = outer - inner - 1.0;
let mut pb = PathBuilder::new();
pb.push_circle(cx, cy, (inner + outer) / 2.0);
if let Some(path) = pb.finish() {
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
// Active sweep — percentage of ring filled in usage colour.
if let Some(p) = percent {
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
if sweep > 0.0 {
let fill = usage_color(kind, p);
let mut paint = Paint::default();
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = outer - inner - 1.0;
stroke.line_cap = tiny_skia::LineCap::Round;
let pb_path = build_arc(cx, cy, (inner + outer) / 2.0, sweep);
if let Some(path) = pb_path {
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
}
pixmap
}
fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<tiny_skia::Path> {
// tiny-skia 0.11 lacks a direct arc primitive. Approximate by sampling
// points along the circumference from the 12 o'clock position clockwise.
let segments = (sweep_fraction * 64.0).ceil() as usize;
let segments = segments.max(1);
let mut pb = PathBuilder::new();
let start_angle: f32 = -std::f32::consts::FRAC_PI_2;
let total = sweep_fraction * std::f32::consts::TAU;
for i in 0..=segments {
let t = i as f32 / segments as f32;
let a = start_angle + t * total;
let x = cx + a.cos() * radius;
let y = cy + a.sin() * radius;
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
pb.finish()
}
fn base_color(kind: ProviderId) -> [u8; 3] {
match kind {
// Warm orange-ish tint reads as "Claude" without copying the
// upstream's exact #D97757; close enough to be familiar.
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
// Cool dark slate for ChatGPT/Codex.
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
// Deep green for OpenCode Go.
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
}
}
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
/// ring readable (Codex sweep stays white instead of charcoal).
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
let c = crate::usage_color::bar_fill_color(kind, true, percent);
[c.r, c.g, c.b]
}
// ---------- Pixmap → HICON ----------
fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
let width = pixmap.width() as i32;
let height = pixmap.height() as i32;
let pixels = pixmap.data(); // tiny-skia premultiplied RGBA bytes
// Build a 32bpp top-down DIB section for the colour bitmap.
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: width,
biHeight: -height,
biPlanes: 1,
biBitCount: 32,
biCompression: 0, // BI_RGB
..Default::default()
},
..Default::default()
};
unsafe {
let hdc = GetDC(HWND::default());
let mut bits: *mut c_void = std::ptr::null_mut();
let color_bmp = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.ok()
.unwrap_or_default();
ReleaseDC(HWND::default(), hdc);
if color_bmp.is_invalid() || bits.is_null() {
return None;
}
// tiny-skia produces RGBA (premultiplied); GDI's DIB is BGRA. Swap.
let pixel_count = (width * height) as usize;
let dst = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
for i in 0..pixel_count {
let r = pixels[i * 4];
let g = pixels[i * 4 + 1];
let b = pixels[i * 4 + 2];
let a = pixels[i * 4 + 3];
dst[i] = (a as u32) << 24 | (r as u32) << 16 | (g as u32) << 8 | (b as u32);
}
// Monochrome AND mask — opaque pixels marked 0, transparent 1.
let mask_row_stride = ((width + 15) / 16) * 2; // 16-bit aligned per scanline
let mut mask = vec![0u8; (mask_row_stride * height) as usize];
for y in 0..height {
for x in 0..width {
let idx = (y * width + x) as usize;
let alpha = pixels[idx * 4 + 3];
if alpha == 0 {
let byte = (y * mask_row_stride + (x / 8)) as usize;
mask[byte] |= 0x80 >> (x % 8);
}
}
}
let mask_bmp: HBITMAP = CreateBitmap(width, height, 1, 1, Some(mask.as_ptr() as *const _));
if mask_bmp.is_invalid() {
let _ = DeleteObject(color_bmp);
return None;
}
let info = ICONINFO {
fIcon: BOOL(1),
xHotspot: 0,
yHotspot: 0,
hbmMask: mask_bmp,
hbmColor: color_bmp,
};
let hicon = CreateIconIndirect(&info).ok();
let _ = DeleteObject(color_bmp);
let _ = DeleteObject(mask_bmp);
hicon
}
}
-22
View File
@@ -1,22 +0,0 @@
// Dispatch the `WM_APP_TRAY` notification message.
//
// Shell_NotifyIconW packs the event in the LOWORD of lparam (the
// underlying mouse-event code) and the icon ID in HIWORD. We translate
// to a `TrayAction` and let the app handle it.
use windows::Win32::Foundation::LPARAM;
use super::TrayAction;
const WM_LBUTTONUP: u32 = 0x0202;
const WM_RBUTTONUP: u32 = 0x0205;
pub fn handle(lparam: LPARAM) -> TrayAction {
let raw = lparam.0 as u32;
let event = raw & 0xFFFF;
match event {
WM_LBUTTONUP => TrayAction::ToggleWidget,
WM_RBUTTONUP => TrayAction::ShowContextMenu,
_ => TrayAction::None,
}
}
-143
View File
@@ -1,143 +0,0 @@
// Tray-area icon management — stateless module-level API.
//
// Each enabled provider gets one notification-area icon. `sync` reconciles
// the set of registered icons with the supplied desired list, issuing
// `NIM_ADD`, `NIM_MODIFY`, or `NIM_DELETE` per icon. We track registration
// state in a private mutex so callers (the app orchestrator) don't have
// to thread a `Manager` through their snapshot/clone pipelines.
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Shell::{
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING, NIM_ADD,
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
};
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
pub mod badge;
pub mod callback;
pub use crate::usage::ProviderId as IconKind;
/// Menu-command ID for the "Show widget" toggle that appears in the
/// right-click menu and is also fired by left-clicking a tray icon.
pub const IDM_TOGGLE_WIDGET: u16 = 50;
/// Notification message routed back to the owner HWND when the user
/// interacts with a tray icon (left/right click).
pub const WM_APP_TRAY: u32 = 0x8003;
#[derive(Clone, Debug)]
pub struct TrayIcon {
pub kind: IconKind,
pub percent: Option<f64>,
pub tooltip: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrayAction {
None,
ToggleWidget,
ShowContextMenu,
}
fn registered() -> &'static Mutex<HashSet<IconKind>> {
static R: OnceLock<Mutex<HashSet<IconKind>>> = OnceLock::new();
R.get_or_init(|| Mutex::new(HashSet::new()))
}
/// Reconcile registered icons with the desired list.
pub fn sync(owner: HWND, desired: &[TrayIcon]) {
let mut current = registered().lock().expect("tray registry mutex poisoned");
let target: HashSet<IconKind> = desired.iter().map(|i| i.kind).collect();
let to_remove: Vec<IconKind> = current.difference(&target).copied().collect();
for kind in to_remove {
unsafe {
let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind));
}
current.remove(&kind);
}
for icon in desired {
let hicon = badge::render_hicon(icon.kind, icon.percent);
let mut data = build_data(owner, icon.kind);
data.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
data.hIcon = hicon;
write_utf16(&mut data.szTip, &icon.tooltip);
let msg = if current.contains(&icon.kind) {
NIM_MODIFY
} else {
NIM_ADD
};
unsafe {
if Shell_NotifyIconW(msg, &data).as_bool() && msg == NIM_ADD {
current.insert(icon.kind);
}
let _ = DestroyIcon(hicon);
}
}
}
/// Show a yellow-warning balloon on an already-registered icon.
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_WARNING);
}
/// Show a blue-info balloon on an already-registered icon.
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_INFO);
}
fn notify_inner(
owner: HWND,
kind: IconKind,
title: &str,
body: &str,
flags: NOTIFY_ICON_INFOTIP_FLAGS,
) {
let mut data = build_data(owner, kind);
data.uFlags = NIF_INFO;
write_utf16(&mut data.szInfoTitle, title);
write_utf16(&mut data.szInfo, body);
data.dwInfoFlags = flags;
unsafe {
let _ = Shell_NotifyIconW(NIM_MODIFY, &data);
}
}
/// Tear down every registered icon. Call from app shutdown if you want to.
#[allow(dead_code)]
pub fn remove_all(owner: HWND) {
let mut current = registered().lock().expect("tray registry mutex poisoned");
for kind in current.drain().collect::<Vec<_>>() {
unsafe {
let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind));
}
}
}
fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW {
NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: owner,
uID: icon_id(kind),
uCallbackMessage: WM_APP_TRAY,
..Default::default()
}
}
fn icon_id(kind: IconKind) -> u32 {
kind.metadata().tray_icon_id
}
fn write_utf16(dst: &mut [u16], src: &str) {
let units: Vec<u16> = src.encode_utf16().collect();
let n = units.len().min(dst.len().saturating_sub(1));
dst[..n].copy_from_slice(&units[..n]);
if n < dst.len() {
dst[n] = 0;
}
}