diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9e03b..b165ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 turquoise dots above it. It replaces the bar chart over an arc. `assets/icon.svg` is the source, and `scripts/generate-icon.py` writes the PNG, ICNS and ICO from it. +- **The menu bar and notification-area icon matches.** It is now a solid arch + bridge with three dots instead of a cloud, redrawn to stay legible at this + size. While an alert is open, an exclamation mark replaces the dots. ### Added - **Monthly cost review.** Export report on Overview saves a chosen month as one self-contained HTML file, ready to print or share: totals and the change diff --git a/src/background/tray.rs b/src/background/tray.rs index 39f800e..473ec23 100644 --- a/src/background/tray.rs +++ b/src/background/tray.rs @@ -1,6 +1,6 @@ //! The menu bar icon (macOS) or notification-area icon (Windows). //! -//! Its title is the month's spend, and the cloud carries an exclamation +//! Its title is the month's spend, and the bridge carries an exclamation //! mark while an alert is open — enough to notice a runaway bill without //! opening anything. A left click opens the panel ([`super::panel`]); a right click //! the menu, which is where Quit lives, since closing the window no longer @@ -72,7 +72,7 @@ pub(super) fn install(status: Entity, cx: &mut App) -> bool { } let icon = match TrayIconBuilder::new() - .with_icon(cloud_icon(false)) + .with_icon(bridge_icon(false)) .with_icon_as_template(true) .with_tooltip("CloudBridge") .with_menu(Box::new(menu)) @@ -130,7 +130,7 @@ fn show_status(status: &Entity, cx: &mut App) { // A plain set_icon drops the template flag on macOS. if let Err(e) = bar .icon - .set_icon_with_as_template(Some(cloud_icon(alerting)), cfg!(target_os = "macos")) + .set_icon_with_as_template(Some(bridge_icon(alerting)), cfg!(target_os = "macos")) { tracing::warn!("Could not change the menu bar icon: {}", e); } @@ -224,57 +224,137 @@ fn anchor(cx: &App) -> panel::Anchor { panel::Anchor::Corner { work_area } } -/// The icon: a cloud, with an exclamation mark cut out of it when -/// `alerting`, drawn here rather than shipped as an image so it needs no -/// asset. The mark is a hole rather than a colour, so it survives the -/// template tinting. On macOS it is black on transparent, a template the menu -/// bar tints for light and dark; Windows draws an icon as it is, on a -/// taskbar that may be either, so there it is a grey that reads on both. -fn cloud_icon(alerting: bool) -> Icon { +/// The icon's width and height in pixels. +const ICON_SIZE: u32 = 44; + +/// The icon: the app icon's arch bridge and three dots as one solid shape, +/// with an exclamation mark in place of the dots when `alerting`. It is drawn +/// here rather than shipped as an image so it needs no asset, and the app +/// icon's slender artwork would not survive this size. On macOS it is black on +/// transparent, a template the menu bar tints for light and dark; Windows draws +/// an icon as it is, on a taskbar that may be either, so there it is a grey +/// that reads on both. +fn bridge_icon(alerting: bool) -> Icon { + Icon::from_rgba(icon_rgba(alerting), ICON_SIZE, ICON_SIZE) + .expect("a square RGBA buffer is a valid icon") +} + +fn icon_rgba(alerting: bool) -> Vec { const INK: [u8; 3] = if cfg!(target_os = "macos") { [0, 0, 0] } else { [0x8a, 0x8a, 0x8a] }; - const SIZE: u32 = 44; const SAMPLES: u32 = 4; - // Lobes and base, in units of the icon's width. - const CIRCLES: [(f32, f32, f32); 3] = - [(0.32, 0.58, 0.17), (0.52, 0.44, 0.23), (0.72, 0.58, 0.15)]; - const BASE: (f32, f32, f32, f32) = (0.16, 0.57, 0.86, 0.75); - - // The exclamation mark: a bar with rounded ends, and a dot. - const BAR: (f32, f32, f32, f32) = (0.52, 0.35, 0.50, 0.042); - const DOT: (f32, f32, f32) = (0.52, 0.635, 0.047); - - let cloud = |x: f32, y: f32| { - CIRCLES - .iter() - .any(|(cx, cy, r)| (x - cx).powi(2) + (y - cy).powi(2) <= r * r) - || (x >= BASE.0 && x <= BASE.2 && y >= BASE.1 && y <= BASE.3) - }; - let mark = |x: f32, y: f32| { - let (bx, top, bottom, half) = BAR; - let along = y.clamp(top, bottom); - (x - bx).powi(2) + (y - along).powi(2) <= half * half - || (x - DOT.0).powi(2) + (y - DOT.1).powi(2) <= DOT.2 * DOT.2 - }; - let inside = |x: f32, y: f32| cloud(x, y) && !(alerting && mark(x, y)); - let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize); - for row in 0..SIZE { - for column in 0..SIZE { + let mut rgba = Vec::with_capacity((ICON_SIZE * ICON_SIZE * 4) as usize); + for row in 0..ICON_SIZE { + for column in 0..ICON_SIZE { let mut covered = 0; for sy in 0..SAMPLES { for sx in 0..SAMPLES { - let x = (column as f32 + (sx as f32 + 0.5) / SAMPLES as f32) / SIZE as f32; - let y = (row as f32 + (sy as f32 + 0.5) / SAMPLES as f32) / SIZE as f32; - covered += inside(x, y) as u32; + let x = (column as f32 + (sx as f32 + 0.5) / SAMPLES as f32) / ICON_SIZE as f32; + let y = (row as f32 + (sy as f32 + 0.5) / SAMPLES as f32) / ICON_SIZE as f32; + covered += icon_covers(x, y, alerting) as u32; } } let alpha = (covered * 255 / (SAMPLES * SAMPLES)) as u8; rgba.extend_from_slice(&[INK[0], INK[1], INK[2], alpha]); } } - Icon::from_rgba(rgba, SIZE, SIZE).expect("a square RGBA buffer is a valid icon") + rgba +} + +/// Whether the shape covers a point, in units of the icon's width. +fn icon_covers(x: f32, y: f32, alerting: bool) -> bool { + // The bridge: a shallow arc for the deck, and an arch cut through it. + const LEFT: f32 = 0.03; + const RIGHT: f32 = 0.97; + const BASE: f32 = 0.88; + const CROWN: f32 = 0.46; + const DECK_END: f32 = 0.60; + const ARCH: (f32, f32, f32) = (0.5, BASE, 0.27); + + const DOTS: [(f32, f32, f32); 3] = + [(0.26, 0.29, 0.08), (0.55, 0.17, 0.11), (0.81, 0.32, 0.085)]; + + // The exclamation mark: a bar with rounded ends, and a dot. + const BAR: (f32, f32, f32, f32) = (0.5, 0.09, 0.20, 0.06); + const MARK_DOT: (f32, f32, f32) = (0.5, 0.355, 0.055); + + let in_disc = |(cx, cy, r): (f32, f32, f32)| (x - cx).powi(2) + (y - cy).powi(2) <= r * r; + + let half_span = (RIGHT - LEFT) / 2.0; + let rise = DECK_END - CROWN; + let deck_radius = (half_span * half_span + rise * rise) / (2.0 * rise); + let deck = CROWN + deck_radius + - (deck_radius * deck_radius - (x - 0.5).powi(2)) + .max(0.0) + .sqrt(); + if (LEFT..=RIGHT).contains(&x) && (deck..=BASE).contains(&y) && !in_disc(ARCH) { + return true; + } + + if alerting { + let (bar_x, top, bottom, half) = BAR; + let along = y.clamp(top, bottom); + (x - bar_x).powi(2) + (y - along).powi(2) <= half * half || in_disc(MARK_DOT) + } else { + DOTS.into_iter().any(in_disc) + } +} + +#[cfg(test)] +mod tests { + use super::{icon_covers, icon_rgba, ICON_SIZE}; + + #[test] + fn the_bridge_has_an_opening_under_a_solid_deck() { + for alerting in [false, true] { + assert!(icon_covers(0.5, 0.55, alerting), "the deck at the crown"); + assert!(icon_covers(0.1, 0.8, alerting), "a pier"); + assert!(icon_covers(0.9, 0.8, alerting), "a pier"); + assert!(!icon_covers(0.5, 0.8, alerting), "the arch"); + } + } + + #[test] + fn an_alert_changes_only_what_is_above_the_bridge() { + for row in 0..ICON_SIZE { + for column in 0..ICON_SIZE { + let (x, y) = ( + (column as f32 + 0.5) / ICON_SIZE as f32, + (row as f32 + 0.5) / ICON_SIZE as f32, + ); + if y >= 0.45 { + assert_eq!(icon_covers(x, y, false), icon_covers(x, y, true)); + } + } + } + assert_ne!(icon_rgba(false), icon_rgba(true)); + } + + #[test] + fn the_mark_stands_clear_of_the_bridge_and_its_own_dot() { + assert!(icon_covers(0.5, 0.13, true), "the bar"); + assert!(icon_covers(0.5, 0.355, true), "the dot"); + assert!(!icon_covers(0.5, 0.275, true), "between bar and dot"); + assert!(!icon_covers(0.5, 0.435, true), "between dot and deck"); + } + + #[test] + fn nothing_is_clipped_at_the_edge_of_the_icon() { + let size = ICON_SIZE as usize; + for alerting in [false, true] { + let rgba = icon_rgba(alerting); + assert_eq!(rgba.len(), size * size * 4); + for row in 0..size { + for column in 0..size { + if row == 0 || column == 0 || row == size - 1 || column == size - 1 { + assert_eq!(rgba[(row * size + column) * 4 + 3], 0); + } + } + } + } + } }