From eb85d731717be3c258b408a8d91bc7e4e8dc6ada Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 4 Nov 2026 00:15:26 +0900 Subject: [PATCH] Draw the arch bridge in the menu bar and notification area The tray still drew the old cloud, because it is drawn in code and #74 only replaced the image files. It is now a solid bridge with three dots, redrawn because the app icon's slender artwork would not read at this size. An exclamation mark replaces the dots while an alert is open, since the bridge has no room for the hole the cloud used. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + src/background/tray.rs | 160 ++++++++++++++++++++++++++++++----------- 2 files changed, 123 insertions(+), 40 deletions(-) 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); + } + } + } + } + } }