mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 22:16:30 +00:00
feat: render the tray badge as straight RGBA for Tauri tray icons
This commit is contained in:
1 parent
a395c66248
commit
539f26019b
5 files changed
+229
-373
No files matched your search
+228
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user