use std::path::PathBuf;
use anyhow::Result;
use pixtuoid_core::sprite::format::{load_pack, load_pack_from_strings, Pack};
fn xdg_pack_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| crate::install::io::user_home().map(|h| PathBuf::from(h).join(".config")))?;
let dir = base.join("pixtuoid").join("sprites");
if dir.join("pack.toml").is_file() {
Some(dir)
} else {
None
}
}
pub fn load_sprite_pack(pack_dir: Option<PathBuf>) -> Result<Pack> {
let base = load_embedded_pack()?;
if let Some(dir) = pack_dir {
let mut custom = load_pack(&dir).map_err(|e| {
anyhow::anyhow!("failed to load sprite pack from {}: {e}", dir.display())
})?;
tracing::info!(path = %dir.display(), "loaded sprite pack from --pack-dir");
custom.merge_from(&base);
return Ok(custom);
}
if let Some(dir) = xdg_pack_dir() {
match load_pack(&dir) {
Ok(mut p) => {
tracing::info!(path = %dir.display(), "loaded user sprite pack");
p.merge_from(&base);
return Ok(p);
}
Err(e) => {
tracing::warn!(
path = %dir.display(),
error = %e,
"user sprite pack failed to load; falling back to embedded default"
);
}
}
}
Ok(base)
}
fn load_embedded_pack() -> Result<Pack> {
let pack_toml = include_str!("../../sprites/default/pack.toml");
let seated = include_str!("../../sprites/default/seated.sprite");
let typing_0 = include_str!("../../sprites/default/typing_0.sprite");
let typing_1 = include_str!("../../sprites/default/typing_1.sprite");
let standing = include_str!("../../sprites/default/standing.sprite");
let walking_0 = include_str!("../../sprites/default/walking_0.sprite");
let walking_1 = include_str!("../../sprites/default/walking_1.sprite");
let walking_back_0 = include_str!("../../sprites/default/walking_back_0.sprite");
let walking_back_1 = include_str!("../../sprites/default/walking_back_1.sprite");
let walking_coffee_0 = include_str!("../../sprites/default/walking_coffee_0.sprite");
let walking_coffee_1 = include_str!("../../sprites/default/walking_coffee_1.sprite");
let desk = include_str!("../../sprites/default/desk.sprite");
let plant = include_str!("../../sprites/default/plant.sprite");
let plant_tall = include_str!("../../sprites/default/plant_tall.sprite");
let plant_fl = include_str!("../../sprites/default/plant_flower.sprite");
let plant_suc = include_str!("../../sprites/default/plant_succulent.sprite");
let floor_lamp = include_str!("../../sprites/default/floor_lamp.sprite");
let trash_bin = include_str!("../../sprites/default/trash_bin.sprite");
let door = include_str!("../../sprites/default/door.sprite");
let door_half = include_str!("../../sprites/default/door_half.sprite");
let door_open = include_str!("../../sprites/default/door_open.sprite");
let bulletin = include_str!("../../sprites/default/bulletin_board.sprite");
let exit_sign = include_str!("../../sprites/default/exit_sign.sprite");
let filing = include_str!("../../sprites/default/filing_cabinet.sprite");
let cat_0 = include_str!("../../sprites/default/cat_walk_0.sprite");
let cat_1 = include_str!("../../sprites/default/cat_walk_1.sprite");
let cat_sit = include_str!("../../sprites/default/cat_sit.sprite");
let cat_sleep = include_str!("../../sprites/default/cat_sleep.sprite");
let dog_0 = include_str!("../../sprites/default/dog_walk_0.sprite");
let dog_1 = include_str!("../../sprites/default/dog_walk_1.sprite");
let dog_sit = include_str!("../../sprites/default/dog_sit.sprite");
let dog_sleep = include_str!("../../sprites/default/dog_sleep.sprite");
let lobster_0 = include_str!("../../sprites/default/lobster_walk_0.sprite");
let lobster_1 = include_str!("../../sprites/default/lobster_walk_1.sprite");
let lobster_rest = include_str!("../../sprites/default/lobster_rest.sprite");
let meeting_sofa = include_str!("../../sprites/default/meeting_sofa.sprite");
let meeting_screen = include_str!("../../sprites/default/meeting_screen.sprite");
let back_couch = include_str!("../../sprites/default/back_couch.sprite");
let sleeping_seat = include_str!("../../sprites/default/seated_sleeping.sprite");
let sleeping_alt = include_str!("../../sprites/default/seated_sleeping_alt.sprite");
let holding = include_str!("../../sprites/default/holding_coffee.sprite");
let pantry = include_str!("../../sprites/default/pantry.sprite");
let pantry_small = include_str!("../../sprites/default/pantry_small.sprite");
let whiteboard = include_str!("../../sprites/default/whiteboard.sprite");
let bookshelf = include_str!("../../sprites/default/bookshelf.sprite");
let tv_stand = include_str!("../../sprites/default/tv_stand.sprite");
let phone_booth = include_str!("../../sprites/default/phone_booth.sprite");
let standing_desk = include_str!("../../sprites/default/standing_desk.sprite");
load_pack_from_strings(
pack_toml,
&[
("seated.sprite", seated),
("typing_0.sprite", typing_0),
("typing_1.sprite", typing_1),
("standing.sprite", standing),
("walking_0.sprite", walking_0),
("walking_1.sprite", walking_1),
("walking_back_0.sprite", walking_back_0),
("walking_back_1.sprite", walking_back_1),
("walking_coffee_0.sprite", walking_coffee_0),
("walking_coffee_1.sprite", walking_coffee_1),
("desk.sprite", desk),
("plant.sprite", plant),
("plant_tall.sprite", plant_tall),
("plant_flower.sprite", plant_fl),
("plant_succulent.sprite", plant_suc),
("floor_lamp.sprite", floor_lamp),
("trash_bin.sprite", trash_bin),
("door.sprite", door),
("door_half.sprite", door_half),
("door_open.sprite", door_open),
("bulletin_board.sprite", bulletin),
("exit_sign.sprite", exit_sign),
("filing_cabinet.sprite", filing),
("cat_walk_0.sprite", cat_0),
("cat_walk_1.sprite", cat_1),
("cat_sit.sprite", cat_sit),
("cat_sleep.sprite", cat_sleep),
("dog_walk_0.sprite", dog_0),
("dog_walk_1.sprite", dog_1),
("dog_sit.sprite", dog_sit),
("dog_sleep.sprite", dog_sleep),
("lobster_walk_0.sprite", lobster_0),
("lobster_walk_1.sprite", lobster_1),
("lobster_rest.sprite", lobster_rest),
("meeting_sofa.sprite", meeting_sofa),
("meeting_screen.sprite", meeting_screen),
("back_couch.sprite", back_couch),
("seated_sleeping.sprite", sleeping_seat),
("seated_sleeping_alt.sprite", sleeping_alt),
("holding_coffee.sprite", holding),
("pantry.sprite", pantry),
("pantry_small.sprite", pantry_small),
("whiteboard.sprite", whiteboard),
("bookshelf.sprite", bookshelf),
("tv_stand.sprite", tv_stand),
("phone_booth.sprite", phone_booth),
("standing_desk.sprite", standing_desk),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
fn copy_skeleton_pack(dst: &Path) {
fs::create_dir_all(dst).expect("mkdir pack dir");
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("sprites/skeleton");
for entry in fs::read_dir(&src).expect("read skeleton dir") {
let entry = entry.expect("dir entry");
let path = entry.path();
if path.is_file() {
let name = path.file_name().expect("file name");
fs::copy(&path, dst.join(name)).expect("copy pack file");
}
}
}
#[test]
fn load_sprite_pack_from_custom_dir_merges_with_embedded() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let pack_dir = tmp.path().join("custom");
copy_skeleton_pack(&pack_dir);
let pack = load_sprite_pack(Some(pack_dir)).expect("custom pack loads");
assert!(
pack.animation("seated").is_some(),
"custom pack must carry the seated character pose"
);
assert!(
pack.animation("desk").is_some(),
"furniture merged from the embedded default"
);
}
#[test]
fn load_sprite_pack_from_missing_custom_dir_errors() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let missing = tmp.path().join("does-not-exist");
assert!(
load_sprite_pack(Some(missing)).is_err(),
"a nonexistent --pack-dir must surface a load error"
);
}
#[test]
fn load_sprite_pack_resolves_then_falls_back_via_xdg() {
let _env = crate::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let saved = std::env::var_os("XDG_CONFIG_HOME");
let good = tempfile::TempDir::new().expect("tempdir");
let good_sprites = good.path().join("pixtuoid").join("sprites");
copy_skeleton_pack(&good_sprites);
std::env::set_var("XDG_CONFIG_HOME", good.path());
let pack = load_sprite_pack(None).expect("xdg pack loads");
assert!(
pack.animation("seated").is_some(),
"the valid XDG pack must be loaded (xdg Ok arm)"
);
let bad = tempfile::TempDir::new().expect("tempdir");
let bad_sprites = bad.path().join("pixtuoid").join("sprites");
fs::create_dir_all(&bad_sprites).expect("mkdir bad sprites");
fs::write(bad_sprites.join("pack.toml"), b"this is not valid toml {{{")
.expect("write malformed pack.toml");
std::env::set_var("XDG_CONFIG_HOME", bad.path());
let fallback = load_sprite_pack(None).expect("malformed pack falls back, never errors");
assert!(
fallback.animation("seated").is_some(),
"fallback to the embedded default after a malformed user pack"
);
match saved {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
#[test]
fn embedded_pack_all_palette_keys_are_distinct_rgbs() {
let pack = load_sprite_pack(None).expect("embedded pack loads");
let entries: Vec<(char, pixtuoid_core::sprite::Rgb)> = pack
.palette
.iter()
.filter_map(|(k, p)| p.map(|rgb| (k, rgb)))
.collect();
for i in 0..entries.len() {
for j in (i + 1)..entries.len() {
assert_ne!(
entries[i].1, entries[j].1,
"palette keys {:?} and {:?} share an RGB — recolor_frame can't distinguish them",
entries[i].0, entries[j].0
);
}
}
}
#[test]
fn embedded_pack_recolor_keys_are_distinct_rgbs() {
let pack = load_sprite_pack(None).expect("embedded pack loads");
let keys = pixtuoid_core::sprite::format::RECOLOR_KEYS;
let rgbs: Vec<_> = keys
.iter()
.map(|&k| {
pack.palette
.get(k)
.flatten()
.unwrap_or_else(|| panic!("embedded pack missing recolor key {k:?}"))
})
.collect();
for i in 0..rgbs.len() {
for j in (i + 1)..rgbs.len() {
assert_ne!(
rgbs[i], rgbs[j],
"recolor keys {:?} and {:?} share an RGB — recolor_frame would swap both",
keys[i], keys[j]
);
}
}
}
}