use windows::Win32::Graphics::Gdi::{
CreateBitmap, DeleteObject, GetDC, GetDeviceCaps, ReleaseDC, LOGPIXELSX,
};
use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, DestroyIcon, HICON, ICONINFO};
use crate::timer::{Phase, State};
const SS: i32 = 4;
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Visual {
pub rgb: (u8, u8, u8),
pub progress: f32,
pub paused: bool,
pub idle: bool,
pub alpha: f32,
}
const PROGRESS_STEPS: f32 = 60.0;
fn quantise(progress: f32) -> f32 {
(progress.clamp(0.0, 1.0) * PROGRESS_STEPS).round() / PROGRESS_STEPS
}
impl Visual {
pub fn from_state(state: &State) -> Visual {
match state {
State::Idle => Visual {
rgb: (0x8A, 0x8E, 0x94),
progress: 1.0,
paused: false,
idle: true,
alpha: 1.0,
},
State::Running { phase, .. } => Visual {
rgb: phase_color(*phase),
progress: quantise(state.progress()),
paused: false,
idle: false,
alpha: 1.0,
},
State::Paused { phase, .. } => Visual {
rgb: phase_color(*phase),
progress: quantise(state.progress()),
paused: true,
idle: false,
alpha: 0.45,
},
}
}
}
fn phase_color(p: Phase) -> (u8, u8, u8) {
match p {
Phase::Focus => (0xE8, 0x56, 0x3F),
Phase::ShortBreak | Phase::LongBreak => (0x35, 0xC4, 0x6A),
}
}
pub struct OwnedIcon(HICON);
impl OwnedIcon {
pub fn handle(&self) -> HICON {
self.0
}
}
impl Drop for OwnedIcon {
fn drop(&mut self) {
if !self.0.is_invalid() {
unsafe {
let _ = DestroyIcon(self.0);
}
}
}
}
pub fn tray_icon_size() -> i32 {
unsafe {
let dc = GetDC(None);
let dpi = if dc.is_invalid() {
96
} else {
let d = GetDeviceCaps(Some(dc), LOGPIXELSX);
ReleaseDC(None, dc);
if d <= 0 {
96
} else {
d
}
};
((16 * dpi) / 96).clamp(16, 64)
}
}
pub fn render(v: Visual, size: i32) -> Option<OwnedIcon> {
let size = size.max(8);
icon_from_pixels(&rasterise(v, size), size)
}
pub fn render_mute_icon(size: i32) -> Option<OwnedIcon> {
let size = size.max(8);
icon_from_pixels(&mute_mark_pixels(size, taskbar_ink()), size)
}
pub fn mute_mark_pixels(size: i32, rgb: (u8, u8, u8)) -> Vec<u8> {
let c = (size as f32 - 1.0) / 2.0;
let mark = MuteMark::new(c, c, size as f32 / 2.0 - 0.5 - size as f32 * MARK_PADDING);
let mut pixels = vec![0u8; (size * size * 4) as usize];
draw_shape(&mut pixels, size, rgb, 1.0, |px, py| mark.covers(px, py));
pixels
}
const MARK_PADDING: f32 = 0.02;
fn icon_from_pixels(pixels: &[u8], size: i32) -> Option<OwnedIcon> {
unsafe {
let color = CreateBitmap(size, size, 1, 32, Some(pixels.as_ptr() as *const _));
if color.is_invalid() {
return None;
}
let mask = CreateBitmap(size, size, 1, 1, None);
if mask.is_invalid() {
let _ = DeleteObject(color.into());
return None;
}
let info = ICONINFO {
fIcon: true.into(),
xHotspot: 0,
yHotspot: 0,
hbmMask: mask,
hbmColor: color,
};
let icon = CreateIconIndirect(&info);
let _ = DeleteObject(color.into());
let _ = DeleteObject(mask.into());
icon.ok().map(OwnedIcon)
}
}
#[derive(Clone, Copy)]
struct MuteMark {
cx: f32,
cy: f32,
r: f32,
}
impl MuteMark {
fn new(cx: f32, cy: f32, r: f32) -> Self {
Self { cx, cy, r }
}
fn covers(&self, px: f32, py: f32) -> bool {
let u = (px - self.cx) / self.r;
let v = (py - self.cy) / self.r;
let detail = self.r >= DETAIL_PX;
let distance = bell(u, v, detail);
let body = distance <= 0.0 && distance >= -self.outline_width();
if !detail {
return body;
}
(body && stroke(u, v, self.gap()) > 0.0) || stroke(u, v, self.line()) <= 0.0
}
fn outline_width(&self) -> f32 {
OUTLINE_WIDTH.max(MIN_OUTLINE_PX / self.r)
}
fn line(&self) -> f32 {
STROKE_WIDTH.max(MIN_STROKE_PX / self.r)
}
fn gap(&self) -> f32 {
self.line() + (STROKE_GAP - STROKE_WIDTH)
}
}
const STROKE_WIDTH: f32 = 0.075;
const STROKE_GAP: f32 = 0.155;
const DETAIL_PX: f32 = 5.6;
const MIN_STROKE_PX: f32 = 0.75;
const OUTLINE_WIDTH: f32 = 0.17;
const MIN_OUTLINE_PX: f32 = 1.25;
fn bell(u: f32, v: f32, detail: bool) -> f32 {
let disc = |cy: f32, r: f32| (u * u + (v - cy) * (v - cy)).sqrt() - r;
let box_ = |cy: f32, hu: f32, hv: f32| {
let (du, dv) = (u.abs() - hu, (v - cy).abs() - hv);
du.max(dv).min(0.0) + (du.max(0.0).powi(2) + dv.max(0.0).powi(2)).sqrt()
};
let far = 9.0f32;
let handle = if detail { disc(-0.88, 0.11) } else { far };
let clapper = if detail { disc(0.82, 0.15) } else { far };
let dome = disc(-0.22, 0.52);
let base = box_(0.54, 0.74, if detail { 0.08 } else { 0.13 });
let skirt = {
const NU: f32 = 0.989;
const NV: f32 = -0.146;
let side = NU * (u.abs() - 0.52) + NV * (v + 0.22);
side.max(-0.22 - v).max(v - 0.46)
};
handle.min(dome).min(skirt).min(base).min(clapper)
}
fn stroke(u: f32, v: f32, half: f32) -> f32 {
const DIAGONAL: f32 = std::f32::consts::FRAC_1_SQRT_2;
let across = ((u + v) * DIAGONAL).abs() - half;
let along = ((u - v) * DIAGONAL).abs() - 0.98;
across.max(along)
}
fn draw_shape(
out: &mut [u8],
size: i32,
rgb: (u8, u8, u8),
alpha: f32,
inside: impl Fn(f32, f32) -> bool,
) {
let n = size as usize;
let samples = (SS * SS) as f32;
for y in 0..size {
for x in 0..size {
let mut hits = 0;
for sy in 0..SS {
for sx in 0..SS {
let px = x as f32 + (sx as f32 + 0.5) / SS as f32 - 0.5;
let py = y as f32 + (sy as f32 + 0.5) / SS as f32 - 0.5;
if inside(px, py) {
hits += 1;
}
}
}
if hits > 0 {
let idx = ((y as usize) * n + x as usize) * 4;
blend_over(out, idx, rgb, alpha * hits as f32 / samples);
}
}
}
}
fn blend_over(out: &mut [u8], idx: usize, rgb: (u8, u8, u8), a: f32) {
let a = a.clamp(0.0, 1.0);
if a <= 0.0 {
return;
}
let inv = 1.0 - a;
for (i, channel) in [rgb.2, rgb.1, rgb.0, 0xFF].into_iter().enumerate() {
let src = channel as f32 * a;
out[idx + i] = (src + out[idx + i] as f32 * inv).min(255.0) as u8;
}
}
fn taskbar_ink() -> (u8, u8, u8) {
let light = crate::os::registry::Key::open_read(
"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
)
.and_then(|k| k.get_u32("SystemUsesLightTheme"))
.is_some_and(|v| v != 0);
if light {
(0x2B, 0x30, 0x42)
} else {
(0xE8, 0xEC, 0xFA)
}
}
#[derive(Clone, Copy)]
struct Style {
thickness: f32,
track_alpha: f32,
track: (u8, u8, u8),
}
impl Default for Style {
fn default() -> Self {
Style {
thickness: 0.22,
track_alpha: 0.22,
track: (0x9A, 0x9F, 0xA6),
}
}
}
fn rasterise(v: Visual, size: i32) -> Vec<u8> {
rasterise_with(v, size, Style::default())
}
fn rasterise_with(v: Visual, size: i32, style: Style) -> Vec<u8> {
let n = size as usize;
let mut out = vec![0u8; n * n * 4];
let c = (size as f32 - 1.0) / 2.0;
let r_out = size as f32 / 2.0 - 0.5;
let thickness = (size as f32 * style.thickness).max(2.0);
let r_in = (r_out - thickness).max(1.0);
let track = style.track;
let track_alpha = if v.idle { 0.0 } else { style.track_alpha };
let sweep = v.progress.clamp(0.0, 1.0) * std::f32::consts::TAU;
let fade = v.alpha.clamp(0.0, 1.0);
for y in 0..size {
for x in 0..size {
let mut acc = [0f32; 4];
for sy in 0..SS {
for sx in 0..SS {
let px = x as f32 + (sx as f32 + 0.5) / SS as f32 - 0.5;
let py = y as f32 + (sy as f32 + 0.5) / SS as f32 - 0.5;
let dx = px - c;
let dy = py - c;
let dist = (dx * dx + dy * dy).sqrt();
if dist > r_out || dist < r_in {
continue;
}
let mut ang = dx.atan2(-dy);
if ang < 0.0 {
ang += std::f32::consts::TAU;
}
let filled = v.idle || ang <= sweep;
let (rgb, a) = if filled {
(v.rgb, fade)
} else {
(track, track_alpha * fade)
};
acc[0] += rgb.2 as f32 * a;
acc[1] += rgb.1 as f32 * a;
acc[2] += rgb.0 as f32 * a;
acc[3] += a;
}
}
let samples = (SS * SS) as f32;
let idx = ((y as usize) * n + x as usize) * 4;
out[idx] = (acc[0] / samples) as u8;
out[idx + 1] = (acc[1] / samples) as u8;
out[idx + 2] = (acc[2] / samples) as u8;
out[idx + 3] = (acc[3] / samples * 255.0) as u8;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn paused_keeps_its_colour_but_fades() {
let p = Visual::from_state(&State::Paused {
phase: Phase::Focus,
total: Duration::from_secs(60),
remaining: Duration::from_secs(30),
});
assert!(p.paused);
assert_eq!(p.rgb, phase_color(Phase::Focus));
assert!(p.alpha < 1.0, "paused must be faded");
let running = Visual::from_state(&State::Running {
phase: Phase::Focus,
total: Duration::from_secs(60),
remaining: Duration::from_secs(30),
});
assert_eq!(running.alpha, 1.0);
assert_ne!(p, running, "paused and running must be distinguishable");
}
#[test]
fn progress_is_quantised_to_avoid_constant_redraws() {
assert_eq!(quantise(0.412), quantise(0.419));
assert_eq!(quantise(0.0), 0.0);
assert_eq!(quantise(1.0), 1.0);
assert!(quantise(0.10) < quantise(0.90));
}
}