From 53658463bdc9838264e05f47aaa4623bc0849c3a Mon Sep 17 00:00:00 2001 From: Amineharrabi Date: Mon, 9 Nov 2026 18:33:30 +0100 Subject: [PATCH] feat(themes): add Minecraft (Claude) taskbar theme Add an editable bundled theme for Claude, alongside the Codex Minecraft theme: hearts show the weekly limit left and a hunger bar below shows the session (5-hour) limit left, with both reset countdowns beside them. - Both bars are ten icons that move in half-icon (5%) steps and drain from the right. The last half icon only disappears at 100% used. - Sprites are 9x9 with an 8-column stride, drawn 16px tall (142x16 strips) using a symmetric 1-2px column pattern, so they stay crisp at 100% scaling and every icon is identical. The widget is 196x44. - Empty hearts are outlines with a transparent fill. - Reset text switches between light and dark with system.dark. Bundled editable themes previously shared one install marker, so a theme added in a later release never reached existing installs. Each bundled theme now has its own marker; a deleted bundled theme stays deleted. Co-Authored-By: Claude Opus 5.5 --- build.rs | 1 + src/theme_engine.rs | 38 +- src/theme_engine/tests.rs | 118 +++++- src/theme_engine/theme_storage.rs | 83 ++-- .../assets/minecraft-claude-hearts-empty.png | Bin 0 -> 164 bytes .../assets/minecraft-claude-hearts-full.png | Bin 0 -> 238 bytes .../assets/minecraft-claude-hunger-empty.png | Bin 0 -> 213 bytes .../assets/minecraft-claude-hunger-full.png | Bin 0 -> 313 bytes src/themes/minecraft-claude.json | 353 ++++++++++++++++++ 9 files changed, 535 insertions(+), 58 deletions(-) create mode 100644 src/themes/assets/minecraft-claude-hearts-empty.png create mode 100644 src/themes/assets/minecraft-claude-hearts-full.png create mode 100644 src/themes/assets/minecraft-claude-hunger-empty.png create mode 100644 src/themes/assets/minecraft-claude-hunger-full.png create mode 100644 src/themes/minecraft-claude.json diff --git a/build.rs b/build.rs index cac09b1c..ff4f8f38 100644 --- a/build.rs +++ b/build.rs @@ -45,6 +45,7 @@ fn build_themes() { "compact-fluent-quad.json", "classic-vertical.json", "minecraft-codex.json", + "minecraft-claude.json", ] { let path = Path::new("src/themes").join(name); println!("cargo:rerun-if-changed={}", path.display()); diff --git a/src/theme_engine.rs b/src/theme_engine.rs index 2cf14d63..b7cd715a 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -23,6 +23,7 @@ pub const CLASSIC_THEME_ID: &str = "classic-usage-widget"; pub const COMPACT_FLUENT_QUAD_THEME_ID: &str = "compact-fluent-quad"; pub const CLASSIC_VERTICAL_THEME_ID: &str = "classic-vertical"; pub const MINECRAFT_THEME_ID: &str = "theme-minecraft"; +pub const MINECRAFT_CLAUDE_THEME_ID: &str = "theme-minecraft-claude"; const BUILTIN_THEME_SOURCES: &[(&str, &str)] = &[ ( @@ -42,11 +43,22 @@ const BUILTIN_THEME_SOURCES: &[(&str, &str)] = &[ /// Bundled starting points are copied into the managed library only when they /// are missing. Their ids are deliberately excluded from `is_builtin_theme_id` /// so users can edit, rename, export, or delete them in Theme Studio. -const BUNDLED_EDITABLE_THEME_SOURCES: &[(&str, &str)] = &[( - MINECRAFT_THEME_ID, - include_str!(concat!(env!("OUT_DIR"), "/minecraft-codex.json")), -)]; -const BUNDLED_EDITABLE_INSTALL_MARKER: &str = ".minecraft-theme-installed"; +/// +/// Entries are (theme id, theme JSON, install marker). Each theme has its own +/// marker so one added in a later release still reaches existing installs, +/// while a bundled theme the user deleted stays deleted. +const BUNDLED_EDITABLE_THEME_SOURCES: &[(&str, &str, &str)] = &[ + ( + MINECRAFT_THEME_ID, + include_str!(concat!(env!("OUT_DIR"), "/minecraft-codex.json")), + ".minecraft-theme-installed", + ), + ( + MINECRAFT_CLAUDE_THEME_ID, + include_str!(concat!(env!("OUT_DIR"), "/minecraft-claude.json")), + ".minecraft-claude-theme-installed", + ), +]; const BUNDLED_THEME_ASSETS: &[(&str, &[u8])] = &[ ( @@ -57,6 +69,22 @@ const BUNDLED_THEME_ASSETS: &[(&str, &[u8])] = &[ "minecraft-full.png", include_bytes!("themes/assets/minecraft-full.png"), ), + ( + "minecraft-claude-hearts-empty.png", + include_bytes!("themes/assets/minecraft-claude-hearts-empty.png"), + ), + ( + "minecraft-claude-hearts-full.png", + include_bytes!("themes/assets/minecraft-claude-hearts-full.png"), + ), + ( + "minecraft-claude-hunger-empty.png", + include_bytes!("themes/assets/minecraft-claude-hunger-empty.png"), + ), + ( + "minecraft-claude-hunger-full.png", + include_bytes!("themes/assets/minecraft-claude-hunger-full.png"), + ), ]; const REMOVED_BUILTIN_THEME_IDS: &[&str] = &[ diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index 73e7c38d..71316224 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -2527,27 +2527,79 @@ fn compact_fluent_quad_widget_respects_usage_direction() { } #[test] -fn bundled_minecraft_theme_is_valid_editable_and_uses_dashboard_v2() { - assert_eq!(BUNDLED_EDITABLE_THEME_SOURCES.len(), 1); - let (expected_id, source) = BUNDLED_EDITABLE_THEME_SOURCES[0]; - let mut theme: ThemeDocument = serde_json::from_str(source).unwrap(); - assert_eq!(expected_id, MINECRAFT_THEME_ID); - assert_eq!(theme.id, MINECRAFT_THEME_ID); - assert!(!theme.is_builtin()); - theme.prepare_runtime(); - assert!(theme.validate().is_empty()); - assert_eq!( - theme.surfaces[0] - .mouse_events - .as_ref() - .map(|events| events.right_click.as_str()), - Some("show_context_menu(\"dashboard-v2\")") - ); +fn bundled_minecraft_themes_are_valid_editable_and_use_dashboard_v2() { + let ids: Vec<_> = BUNDLED_EDITABLE_THEME_SOURCES + .iter() + .map(|(id, _, _)| *id) + .collect(); + assert_eq!(ids, [MINECRAFT_THEME_ID, MINECRAFT_CLAUDE_THEME_ID]); + + let mut markers: Vec<_> = BUNDLED_EDITABLE_THEME_SOURCES + .iter() + .map(|(_, _, marker)| *marker) + .collect(); + markers.sort_unstable(); + markers.dedup(); + assert_eq!(markers.len(), BUNDLED_EDITABLE_THEME_SOURCES.len()); + + let mut themes = Vec::new(); + for (expected_id, source, _) in BUNDLED_EDITABLE_THEME_SOURCES { + let mut theme: ThemeDocument = serde_json::from_str(source).unwrap(); + assert_eq!(theme.id, *expected_id); + assert!(!theme.is_builtin()); + theme.prepare_runtime(); + assert!(theme.validate().is_empty(), "{:?}", theme.validate()); + assert_eq!( + theme.surfaces[0] + .mouse_events + .as_ref() + .map(|events| events.right_click.as_str()), + Some("show_context_menu(\"dashboard-v2\")") + ); + themes.push(theme); + } for (file_name, bytes) in BUNDLED_THEME_ASSETS { assert!(file_name.starts_with("minecraft-")); assert!(image::load_from_memory(bytes).is_ok()); - assert_eq!(theme_asset_usage(&theme, &format!("assets/{file_name}")), 1); + let usage: usize = themes + .iter() + .map(|theme| theme_asset_usage(theme, &format!("assets/{file_name}"))) + .sum(); + assert_eq!(usage, 1, "{file_name}"); + } +} + +#[test] +fn bundled_minecraft_claude_bars_snap_to_half_icons() { + let mut theme: ThemeDocument = + serde_json::from_str(BUNDLED_EDITABLE_THEME_SOURCES[1].1).unwrap(); + theme.prepare_runtime(); + let layer = |id: &str| { + theme.surfaces[0] + .children + .iter() + .find(|child| child.id == id) + .unwrap_or_else(|| panic!("missing layer {id}")) + .clone() + }; + let hearts_width = layer("mcc-hearts-full-clip").width; + let hunger_width = layer("mcc-hunger-full-clip").width; + + // (weekly % used, session % used, hearts clip, hunger clip). Strips are + // 142px wide; each half icon is 7px plus the 2px leading outline column. + for (weekly, session, hearts, hunger) in [ + (0.0, 0.0, 142.0, 142.0), + (1.0, 1.0, 142.0, 142.0), + (62.0, 37.0, 58.0, 93.0), + (99.0, 100.0, 9.0, 2.0), + (100.0, 99.0, 2.0, 9.0), + ] { + let mut context = DataContext::default(); + context.insert("claude.weekly.percentage", weekly); + context.insert("claude.session.percentage", session); + assert_eq!(evaluate(&hearts_width.0, &context).unwrap(), hearts, "{weekly}%"); + assert_eq!(evaluate(&hunger_width.0, &context).unwrap(), hunger, "{session}%"); } } @@ -2613,6 +2665,38 @@ fn bundled_minecraft_install_preserves_user_edits() { std::fs::remove_dir_all(&root).unwrap(); } +#[test] +fn bundled_minecraft_claude_theme_reaches_existing_installs() { + let root = std::env::temp_dir().join(format!( + "claude-code-usage-monitor-minecraft-claude-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let themes = root.join("themes"); + let assets = themes.join("assets"); + // An install from before this theme existed: only the original marker. + std::fs::create_dir_all(&themes).unwrap(); + std::fs::write(themes.join(".minecraft-theme-installed"), b"1").unwrap(); + + ensure_bundled_editable_themes(&themes, &assets).unwrap(); + let claude_path = themes.join(format!("{MINECRAFT_CLAUDE_THEME_ID}.json")); + assert!(load_theme(&claude_path).is_ok()); + // The original theme's deletion is still respected. + assert!(!themes.join(format!("{MINECRAFT_THEME_ID}.json")).exists()); + for (file_name, _) in BUNDLED_THEME_ASSETS { + assert!(assets.join(file_name).is_file()); + } + + std::fs::remove_file(&claude_path).unwrap(); + ensure_bundled_editable_themes(&themes, &assets).unwrap(); + assert!(!claude_path.exists()); + + std::fs::remove_dir_all(&root).unwrap(); +} + #[test] fn theme_deletion_is_limited_to_managed_writable_themes() { let managed = themes_directory().join("deletable-theme.json"); diff --git a/src/theme_engine/theme_storage.rs b/src/theme_engine/theme_storage.rs index 3019932c..73e3d6ed 100644 --- a/src/theme_engine/theme_storage.rs +++ b/src/theme_engine/theme_storage.rs @@ -327,8 +327,11 @@ pub(super) fn ensure_bundled_editable_themes( directory: &Path, asset_directory: &Path, ) -> Result<(), String> { - let install_marker = directory.join(BUNDLED_EDITABLE_INSTALL_MARKER); - if install_marker.exists() { + let pending: Vec<_> = BUNDLED_EDITABLE_THEME_SOURCES + .iter() + .filter(|(_, _, marker)| !directory.join(marker).exists()) + .collect(); + if pending.is_empty() { return Ok(()); } std::fs::create_dir_all(asset_directory).map_err(|error| error.to_string())?; @@ -339,43 +342,51 @@ pub(super) fn ensure_bundled_editable_themes( } } - for (expected_id, source) in BUNDLED_EDITABLE_THEME_SOURCES { - let mut bundled: ThemeDocument = serde_json::from_str(source).map_err(|error| { - format!("Bundled editable theme '{expected_id}' is invalid JSON: {error}") - })?; - if bundled.id != *expected_id { - return Err(format!( - "Bundled editable theme id '{}' does not match '{expected_id}'", - bundled.id - )); - } - bundled.prepare_runtime(); - let errors = bundled.validate(); - if !errors.is_empty() { - return Err(format!( - "Bundled editable theme '{}' is invalid:\n{}", - bundled.name, - errors.join("\n") - )); - } + for (expected_id, source, marker) in pending { + install_bundled_editable_theme(directory, expected_id, source)?; + std::fs::write(directory.join(marker), b"1").map_err(|error| error.to_string())?; + } + Ok(()) +} - let path = directory.join(format!("{expected_id}.json")); - if !path.exists() { - crate::app_settings::write_json_atomic(&path, &bundled)?; - continue; - } +fn install_bundled_editable_theme( + directory: &Path, + expected_id: &str, + source: &str, +) -> Result<(), String> { + let mut bundled: ThemeDocument = serde_json::from_str(source).map_err(|error| { + format!("Bundled editable theme '{expected_id}' is invalid JSON: {error}") + })?; + if bundled.id != expected_id { + return Err(format!( + "Bundled editable theme id '{}' does not match '{expected_id}'", + bundled.id + )); + } + bundled.prepare_runtime(); + let errors = bundled.validate(); + if !errors.is_empty() { + return Err(format!( + "Bundled editable theme '{}' is invalid:\n{}", + bundled.name, + errors.join("\n") + )); + } - // Upgrade the original locally-created Minecraft theme without - // replacing any other user edits. Once changed, later menu choices are - // preserved because only the old prototype reference is recognized. - let Ok(mut installed) = load_theme(&path) else { - continue; - }; - if migrate_minecraft_context_menu(&mut installed) { - crate::app_settings::write_json_atomic(&path, &installed)?; - } + let path = directory.join(format!("{expected_id}.json")); + if !path.exists() { + return crate::app_settings::write_json_atomic(&path, &bundled); + } + + // Upgrade the original locally-created Minecraft theme without + // replacing any other user edits. Once changed, later menu choices are + // preserved because only the old prototype reference is recognized. + let Ok(mut installed) = load_theme(&path) else { + return Ok(()); + }; + if migrate_minecraft_context_menu(&mut installed) { + crate::app_settings::write_json_atomic(&path, &installed)?; } - std::fs::write(install_marker, b"1").map_err(|error| error.to_string())?; Ok(()) } diff --git a/src/themes/assets/minecraft-claude-hearts-empty.png b/src/themes/assets/minecraft-claude-hearts-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..81bf4048fb61bbc787db269647645e196ea5b9b3 GIT binary patch literal 164 zcmeAS@N?(olHy`uVBq!ia0vp^eLyV0!3HD`Omw#fQrVs^jv*Dd-d=a)I-tPg5_tLd z{hN79oj5ZWPT8|x=<$8t#m8>UJZ+Wtt)h2tG6Vn2=`E?>D|l^tn}s$m(ct1en*AzM zW{Q9e2OkT&{r|r7b04Luq}bP+_0NnfTehyU@_cBkzx!Bg0002BNkl{GU{dXSjfnHC{(MA&e3IYB;bIQYvDXPe13@P) z$M|}!Bg0001-Nkl{RMp{m3aU47*&vB*=oq#{aj7aF^5;rqjBI};|vK* zd|=}h^JpCSlzz7)-R!Bg00031NklKzRXrY8>I+ zIA!7h29Gno=Su*#54Ql^FIGnzN?uT2Jvh&!5db&CVJT*xUjSTRT~uFWsvnjYoTsBo z_;bEbQev$Gm_}!eU(+ImlgnBHn=aP!g7efk;AQQ6)HaR;3BCyw;u?M|iOV1Ct`&H