Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 120 additions & 40 deletions src/background/tray.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -72,7 +72,7 @@ pub(super) fn install(status: Entity<Status>, 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))
Expand Down Expand Up @@ -130,7 +130,7 @@ fn show_status(status: &Entity<Status>, 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);
}
Expand Down Expand Up @@ -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<u8> {
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);
}
}
}
}
}
}
Loading