Skip to content
Open
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
1 change: 1 addition & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
38 changes: 33 additions & 5 deletions src/theme_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)] = &[
(
Expand All @@ -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])] = &[
(
Expand All @@ -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] = &[
Expand Down
118 changes: 101 additions & 17 deletions src/theme_engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}%");
}
}

Expand Down Expand Up @@ -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");
Expand Down
83 changes: 47 additions & 36 deletions src/theme_engine/theme_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand All @@ -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(())
}

Expand Down
Binary file added src/themes/assets/minecraft-claude-hearts-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/themes/assets/minecraft-claude-hearts-full.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/themes/assets/minecraft-claude-hunger-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/themes/assets/minecraft-claude-hunger-full.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading