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 00000000..81bf4048 Binary files /dev/null and b/src/themes/assets/minecraft-claude-hearts-empty.png differ diff --git a/src/themes/assets/minecraft-claude-hearts-full.png b/src/themes/assets/minecraft-claude-hearts-full.png new file mode 100644 index 00000000..d8707a73 Binary files /dev/null and b/src/themes/assets/minecraft-claude-hearts-full.png differ diff --git a/src/themes/assets/minecraft-claude-hunger-empty.png b/src/themes/assets/minecraft-claude-hunger-empty.png new file mode 100644 index 00000000..6c688dc8 Binary files /dev/null and b/src/themes/assets/minecraft-claude-hunger-empty.png differ diff --git a/src/themes/assets/minecraft-claude-hunger-full.png b/src/themes/assets/minecraft-claude-hunger-full.png new file mode 100644 index 00000000..6767e9e9 Binary files /dev/null and b/src/themes/assets/minecraft-claude-hunger-full.png differ diff --git a/src/themes/minecraft-claude.json b/src/themes/minecraft-claude.json new file mode 100644 index 00000000..224bf3dd --- /dev/null +++ b/src/themes/minecraft-claude.json @@ -0,0 +1,353 @@ +{ + "schema_version": 1, + "id": "theme-minecraft-claude", + "name": "Minecraft (Claude)", + "surfaces": [ + { + "id": "mcc-surface", + "name": "Minecraft", + "render": "1", + "visibility": "100", + "placement": { + "reference": { + "region": "system_tray", + "display": 0 + }, + "nest": "taskbar", + "horizontal": "left", + "vertical": "bottom", + "surface_horizontal": "right", + "surface_vertical": "bottom", + "offset_x": -4, + "offset_y": 0 + }, + "width": "196", + "height": "44", + "background": { + "type": "none" + }, + "border": null, + "mouse_events": { + "double_click": "show_dashboard()", + "right_click": "show_context_menu(\"dashboard-v2\")" + }, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + }, + "children": [ + { + "id": "mcc-hearts", + "name": "Hearts (weekly)", + "render": "1", + "visibility": "100", + "y": "5", + "width": "142", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-hearts-empty", + "name": "Hearts Empty", + "render": "1", + "visibility": "100", + "parent": "mcc-hearts", + "width": "142", + "height": "16", + "background": { + "type": "image", + "path": "assets/minecraft-claude-hearts-empty.png", + "fit": "contain" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-hearts-full-clip", + "name": "Hearts Full Container", + "render": "1", + "visibility": "100", + "parent": "mcc-hearts", + "width": "floor((104 - clamp(round(claude.weekly.percentage), 0, 100)) / 5) * 7 + 2", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-hearts-full", + "name": "Hearts Full", + "render": "1", + "visibility": "100", + "parent": "mcc-hearts-full-clip", + "width": "142", + "height": "16", + "background": { + "type": "image", + "path": "assets/minecraft-claude-hearts-full.png", + "fit": "contain" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-hunger", + "name": "Hunger (session)", + "render": "1", + "visibility": "100", + "y": "23", + "width": "142", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-hunger-empty", + "name": "Hunger Empty", + "render": "1", + "visibility": "100", + "parent": "mcc-hunger", + "width": "142", + "height": "16", + "background": { + "type": "image", + "path": "assets/minecraft-claude-hunger-empty.png", + "fit": "contain" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-hunger-full-clip", + "name": "Hunger Full Container", + "render": "1", + "visibility": "100", + "parent": "mcc-hunger", + "width": "floor((104 - clamp(round(claude.session.percentage), 0, 100)) / 5) * 7 + 2", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-hunger-full", + "name": "Hunger Full", + "render": "1", + "visibility": "100", + "parent": "mcc-hunger-full-clip", + "width": "142", + "height": "16", + "background": { + "type": "image", + "path": "assets/minecraft-claude-hunger-full.png", + "fit": "contain" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mcc-weekly-reset-dark", + "name": "Weekly Reset (dark)", + "render": "system.dark", + "visibility": "100", + "anchor": { + "horizontal": "right", + "vertical": "top" + }, + "y": "5", + "width": "48", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{claude.weekly.reset.seconds:duration}", + "font_family": "Segoe UI Variable Text", + "font_size": "11", + "weight": "regular", + "rendering": "antialiased", + "contrast": "1", + "align": "right", + "color": { + "color": "#FFFFFFFF", + "opacity": "1" + } + } + }, + { + "id": "mcc-weekly-reset-light", + "name": "Weekly Reset (light)", + "render": "1 - system.dark", + "visibility": "100", + "anchor": { + "horizontal": "right", + "vertical": "top" + }, + "y": "5", + "width": "48", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{claude.weekly.reset.seconds:duration}", + "font_family": "Segoe UI Variable Text", + "font_size": "11", + "weight": "regular", + "rendering": "antialiased", + "contrast": "1", + "align": "right", + "color": { + "color": "#FF1A1A1A", + "opacity": "1" + } + } + }, + { + "id": "mcc-session-reset-dark", + "name": "Session Reset (dark)", + "render": "system.dark", + "visibility": "100", + "anchor": { + "horizontal": "right", + "vertical": "top" + }, + "y": "23", + "width": "48", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{claude.session.reset.seconds:duration}", + "font_family": "Segoe UI Variable Text", + "font_size": "11", + "weight": "regular", + "rendering": "antialiased", + "contrast": "1", + "align": "right", + "color": { + "color": "#FFFFFFFF", + "opacity": "1" + } + } + }, + { + "id": "mcc-session-reset-light", + "name": "Session Reset (light)", + "render": "1 - system.dark", + "visibility": "100", + "anchor": { + "horizontal": "right", + "vertical": "top" + }, + "y": "23", + "width": "48", + "height": "16", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{claude.session.reset.seconds:duration}", + "font_family": "Segoe UI Variable Text", + "font_size": "11", + "weight": "regular", + "rendering": "antialiased", + "contrast": "1", + "align": "right", + "color": { + "color": "#FF1A1A1A", + "opacity": "1" + } + } + } + ] + } + ] +}