feat(tui): port Codex App pets

This commit is contained in:
Felipe Coury
2026-05-03 20:41:44 -03:00
parent 8ec8b3d882
commit e657c1f5a6
18 changed files with 480 additions and 250 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

View File

@@ -1,62 +0,0 @@
{
"id": "boba",
"displayName": "Boba",
"description": "A tiny otter sipping bubble tea while keeping you company in Codex.",
"spritesheetPath": "spritesheet.webp",
"frame": {
"width": 192,
"height": 208,
"columns": 8,
"rows": 9
},
"animations": {
"idle": {
"frames": [0, 1, 2, 3, 4, 5],
"fps": 5
},
"move_left": {
"frames": [8, 9, 10, 11, 12, 13, 14, 15],
"fps": 10
},
"move_right": {
"frames": [16, 17, 18, 19, 20, 21, 22, 23],
"fps": 10
},
"wave": {
"frames": [24, 25, 26, 27],
"fps": 7,
"loop": false,
"fallback": "idle"
},
"sit": {
"frames": [32, 33, 34, 35, 36],
"fps": 6
},
"sad": {
"frames": [40, 41, 42, 43, 44, 45, 46],
"fps": 6
},
"sleep": {
"frames": [43, 44, 47],
"fps": 3
},
"sip": {
"frames": [48, 49, 50, 51, 52, 53],
"fps": 8,
"loop": false,
"fallback": "idle"
},
"bounce": {
"frames": [56, 57, 58, 59, 60, 61],
"fps": 9,
"loop": false,
"fallback": "idle"
},
"grumpy": {
"frames": [64, 65, 66, 67, 68, 69],
"fps": 6,
"loop": false,
"fallback": "idle"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -1,23 +0,0 @@
{
"id": "codex",
"displayName": "Codex",
"description": "The original Codex companion.",
"spritesheetPath": "spritesheet.webp",
"frame": {
"width": 192,
"height": 208,
"columns": 8,
"rows": 9
},
"animations": {
"idle": { "frames": [0, 1, 2, 3, 4, 5], "fps": 1.5 },
"move_right": { "frames": [8, 9, 10, 11, 12, 13, 14, 15], "fps": 8 },
"move_left": { "frames": [16, 17, 18, 19, 20, 21, 22, 23], "fps": 8 },
"wave": { "frames": [24, 25, 26, 27], "fps": 7, "loop": false, "fallback": "idle" },
"bounce": { "frames": [32, 33, 34, 35, 36], "fps": 7, "loop": false, "fallback": "idle" },
"failed": { "frames": [40, 41, 42, 43, 44, 45, 46, 47], "fps": 7 },
"waiting": { "frames": [48, 49, 50, 51, 52, 53], "fps": 6 },
"running": { "frames": [56, 57, 58, 59, 60, 61], "fps": 8 },
"review": { "frames": [64, 65, 66, 67, 68, 69], "fps": 6 }
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 867 KiB

View File

@@ -6,9 +6,13 @@ expression: popup
Choose a pet to wake in the terminal.
Type to filter pets...
Boba A tiny otter sipping bubble tea while keeping you company
in Codex.
BSOD A tiny blue-screen gremlin.
Codex (current) The original Codex companion.
Dewey A tidy duck for calm workspace days.
Fireball Hot path energy for fast iteration.
None Disable terminal pets.
Null Signal Quiet signal from the void.
Rocky A steady rock when the diff gets large.
Seedy Small green shoots for new ideas.
Press enter to confirm or esc to go back

View File

@@ -125,9 +125,11 @@ impl AmbientPet {
codex_home: &std::path::Path,
frame_requester: FrameRequester,
) -> Result<Self> {
let pet =
Pet::load_with_codex_home(selected_pet.unwrap_or(DEFAULT_PET_ID), Some(codex_home))
.with_context(|| "load ambient pet")?;
let pet = Pet::load_with_codex_home(
selected_pet.unwrap_or(DEFAULT_PET_ID),
/*codex_home*/ Some(codex_home),
)
.with_context(|| "load ambient pet")?;
let cache_dir = frames::cache_dir().join("tui-pets").join(&pet.id);
let frame_dir = cache_dir.join("frames");
let sixel_dir = cache_dir.join("sixel");

View File

@@ -0,0 +1,81 @@
//! Built-in pet catalog ported from the Codex App avatar catalog.
use std::path::Path;
use std::path::PathBuf;
pub(super) const DEFAULT_FRAME_WIDTH: u32 = 192;
pub(super) const DEFAULT_FRAME_HEIGHT: u32 = 208;
pub(super) const DEFAULT_FRAME_COLUMNS: u32 = 8;
pub(super) const DEFAULT_FRAME_ROWS: u32 = 9;
pub(super) const SPRITESHEET_WIDTH: u32 = DEFAULT_FRAME_WIDTH * DEFAULT_FRAME_COLUMNS;
pub(super) const SPRITESHEET_HEIGHT: u32 = DEFAULT_FRAME_HEIGHT * DEFAULT_FRAME_ROWS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct BuiltinPet {
pub(super) id: &'static str,
pub(super) display_name: &'static str,
pub(super) description: &'static str,
pub(super) spritesheet_file: &'static str,
}
pub(super) const BUILTIN_PETS: &[BuiltinPet] = &[
BuiltinPet {
id: "codex",
display_name: "Codex",
description: "The original Codex companion.",
spritesheet_file: "codex-spritesheet-v3.webp",
},
BuiltinPet {
id: "dewey",
display_name: "Dewey",
description: "A tidy duck for calm workspace days.",
spritesheet_file: "dewey-spritesheet-v3.webp",
},
BuiltinPet {
id: "fireball",
display_name: "Fireball",
description: "Hot path energy for fast iteration.",
spritesheet_file: "fireball-spritesheet-v3.webp",
},
BuiltinPet {
id: "rocky",
display_name: "Rocky",
description: "A steady rock when the diff gets large.",
spritesheet_file: "rocky-spritesheet-v3.webp",
},
BuiltinPet {
id: "seedy",
display_name: "Seedy",
description: "Small green shoots for new ideas.",
spritesheet_file: "seedy-spritesheet-v3.webp",
},
BuiltinPet {
id: "stacky",
display_name: "Stacky",
description: "A balanced stack for deep work.",
spritesheet_file: "stacky-spritesheet-v3.webp",
},
BuiltinPet {
id: "bsod",
display_name: "BSOD",
description: "A tiny blue-screen gremlin.",
spritesheet_file: "bsod-spritesheet-v3.webp",
},
BuiltinPet {
id: "null-signal",
display_name: "Null Signal",
description: "Quiet signal from the void.",
spritesheet_file: "null-signal-spritesheet-v3.webp",
},
];
pub(super) fn builtin_pet(id: &str) -> Option<BuiltinPet> {
BUILTIN_PETS.iter().copied().find(|pet| pet.id == id)
}
pub(super) fn builtin_spritesheet_path(file: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("pets")
.join("assets")
.join(file)
}

View File

@@ -1,18 +1,21 @@
//! Ambient terminal pets configured from the /pets slash command.
//!
//! The Codex app stores custom pets under $CODEX_HOME/pets/<pet-id>/pet.json.
//! The Codex app stores default pets as bundled spritesheet assets and custom pets under
//! $CODEX_HOME/pets/<pet-id>/pet.json.
//! This module keeps that package shape intact while rendering the selected pet inline in the TUI.
use std::io::Write;
use anyhow::Context;
use anyhow::Result;
mod ambient;
mod catalog;
mod frames;
mod image_protocol;
mod model;
mod picker;
use anyhow::Context;
use anyhow::Result;
pub(crate) use ambient::AmbientPet;
pub(crate) use ambient::AmbientPetDraw;
pub(crate) use ambient::PetNotificationKind;

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::fs;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
@@ -8,6 +9,8 @@ use anyhow::Result;
use anyhow::bail;
use serde::Deserialize;
use super::catalog;
#[derive(Debug, Clone)]
pub struct Animation {
pub frames: Vec<usize>,
@@ -30,58 +33,20 @@ pub struct Pet {
}
impl Pet {
pub(super) fn load(value: &str) -> Result<Self> {
Self::load_with_codex_home(
value,
crate::legacy_core::config::find_codex_home()
.ok()
.as_deref(),
)
}
pub(super) fn load_with_codex_home(value: &str, codex_home: Option<&Path>) -> Result<Self> {
let pet_dir = resolve_pet_dir(value, codex_home)?;
let config_path = pet_dir.join("pet.json");
let raw = fs::read_to_string(&config_path)
.with_context(|| format!("read {}", config_path.display()))?;
let mut file: PetFile = serde_json::from_str(&raw)
.with_context(|| format!("parse {}", config_path.display()))?;
if file.id.is_empty() {
file.id = pet_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("pet")
.to_string();
}
if file.display_name.is_empty() {
file.display_name.clone_from(&file.id);
}
if file.spritesheet_path.is_empty() {
file.spritesheet_path = "spritesheet.webp".to_string();
if path_like(value) {
return load_pet_path(value);
}
let frame = file.frame.unwrap_or_default();
let spritesheet_path = if Path::new(&file.spritesheet_path).is_absolute() {
PathBuf::from(&file.spritesheet_path)
} else {
pet_dir.join(&file.spritesheet_path)
};
if !spritesheet_path.exists() {
bail!("missing spritesheet {}", spritesheet_path.display());
if let Some(custom_id) = value.strip_prefix(CUSTOM_PET_PREFIX) {
return load_custom_pet(custom_id, codex_home);
}
Ok(Self {
id: file.id,
display_name: file.display_name,
description: file.description,
spritesheet_path,
frame_width: frame.width,
frame_height: frame.height,
columns: frame.columns,
rows: frame.rows,
animations: load_animations(file.animations),
})
if let Some(builtin) = catalog::builtin_pet(value) {
return load_builtin_pet(builtin);
}
load_custom_pet(value, codex_home)
}
pub fn frame_count(&self) -> usize {
@@ -89,16 +54,18 @@ impl Pet {
}
}
pub(super) const CUSTOM_PET_PREFIX: &str = "custom:";
#[derive(Debug, Deserialize)]
struct PetFile {
#[serde(default)]
id: String,
id: Option<String>,
#[serde(default, rename = "displayName")]
display_name: String,
display_name: Option<String>,
#[serde(default)]
description: String,
description: Option<String>,
#[serde(default, rename = "spritesheetPath")]
spritesheet_path: String,
spritesheet_path: Option<String>,
frame: Option<FrameSpec>,
#[serde(default)]
animations: HashMap<String, AnimationSpec>,
@@ -115,14 +82,18 @@ struct FrameSpec {
impl Default for FrameSpec {
fn default() -> Self {
Self {
width: 192,
height: 208,
columns: 8,
rows: 9,
width: catalog::DEFAULT_FRAME_WIDTH,
height: catalog::DEFAULT_FRAME_HEIGHT,
columns: catalog::DEFAULT_FRAME_COLUMNS,
rows: catalog::DEFAULT_FRAME_ROWS,
}
}
}
pub(super) fn custom_pet_selector(id: &str) -> String {
format!("{CUSTOM_PET_PREFIX}{id}")
}
#[derive(Debug, Deserialize)]
struct AnimationSpec {
#[serde(default)]
@@ -134,37 +105,160 @@ struct AnimationSpec {
fallback: String,
}
fn resolve_pet_dir(value: &str, codex_home: Option<&Path>) -> Result<PathBuf> {
if path_like(value) {
let path = expand_path(value)?;
let metadata =
fs::metadata(&path).with_context(|| format!("pet path {}", path.display()))?;
let dir = if metadata.is_dir() {
path
} else {
path.parent()
.context("pet json path has no containing directory")?
.to_path_buf()
};
return dir
.canonicalize()
.with_context(|| format!("resolve {}", dir.display()));
fn load_builtin_pet(pet: catalog::BuiltinPet) -> Result<Pet> {
let spritesheet_path = catalog::builtin_spritesheet_path(pet.spritesheet_file);
if !spritesheet_path.exists() {
bail!("missing spritesheet {}", spritesheet_path.display());
}
Ok(resolve_named_pet_dir(value, codex_home))
Ok(Pet {
id: pet.id.to_string(),
display_name: pet.display_name.to_string(),
description: pet.description.to_string(),
spritesheet_path,
frame_width: catalog::DEFAULT_FRAME_WIDTH,
frame_height: catalog::DEFAULT_FRAME_HEIGHT,
columns: catalog::DEFAULT_FRAME_COLUMNS,
rows: catalog::DEFAULT_FRAME_ROWS,
animations: default_animations(),
})
}
fn resolve_named_pet_dir(value: &str, codex_home: Option<&Path>) -> PathBuf {
if let Some(codex_home) = codex_home {
let installed_pet = codex_home.join("pets").join(value);
if installed_pet.join("pet.json").is_file() {
return installed_pet;
}
fn load_custom_pet(value: &str, codex_home: Option<&Path>) -> Result<Pet> {
let codex_home = codex_home.context("CODEX_HOME is not available")?;
let pet_dir = codex_home.join("pets").join(value);
if pet_dir.join("pet.json").is_file() {
return load_pet_manifest(&pet_dir, "pet.json", value, &custom_pet_cache_id(value));
}
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("pets")
.join(value)
let avatar_dir = codex_home.join("avatars").join(value);
if avatar_dir.join("avatar.json").is_file() {
return load_pet_manifest(
&avatar_dir,
"avatar.json",
value,
&custom_pet_cache_id(value),
);
}
bail!("unknown pet {value}")
}
fn load_pet_path(value: &str) -> Result<Pet> {
let path = expand_path(value)?;
let metadata = fs::metadata(&path).with_context(|| format!("pet path {}", path.display()))?;
let dir = if metadata.is_dir() {
path
} else {
path.parent()
.context("pet json path has no containing directory")?
.to_path_buf()
};
let pet_dir = dir
.canonicalize()
.with_context(|| format!("resolve {}", dir.display()))?;
let manifest_file = if pet_dir.join("pet.json").is_file() {
"pet.json"
} else if pet_dir.join("avatar.json").is_file() {
"avatar.json"
} else {
bail!("missing pet.json or avatar.json in {}", pet_dir.display());
};
let fallback_id = pet_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("pet");
load_pet_manifest(&pet_dir, manifest_file, fallback_id, fallback_id)
}
fn load_pet_manifest(
pet_dir: &Path,
manifest_file: &str,
fallback_id: &str,
cache_id: &str,
) -> Result<Pet> {
let config_path = pet_dir.join(manifest_file);
let raw = fs::read_to_string(&config_path)
.with_context(|| format!("read {}", config_path.display()))?;
let file: PetFile =
serde_json::from_str(&raw).with_context(|| format!("parse {}", config_path.display()))?;
let manifest_id = file
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty());
let display_name = file
.display_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.or(manifest_id)
.unwrap_or(fallback_id)
.to_string();
let pet_id = if cache_id == fallback_id {
manifest_id.unwrap_or(fallback_id).to_string()
} else {
cache_id.to_string()
};
let description = file
.description
.map(|description| description.trim().to_string())
.unwrap_or_default();
let spritesheet_path = resolve_spritesheet_path(
pet_dir,
file.spritesheet_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.unwrap_or("spritesheet.webp"),
)?;
if !spritesheet_path.exists() {
bail!("missing spritesheet {}", spritesheet_path.display());
}
validate_app_spritesheet_dimensions(&spritesheet_path)?;
let frame = file.frame.unwrap_or_default();
Ok(Pet {
id: pet_id,
display_name,
description,
spritesheet_path,
frame_width: frame.width,
frame_height: frame.height,
columns: frame.columns,
rows: frame.rows,
animations: load_animations(file.animations),
})
}
fn resolve_spritesheet_path(pet_dir: &Path, spritesheet_path: &str) -> Result<PathBuf> {
let path = Path::new(spritesheet_path);
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
{
bail!("spritesheet path must stay inside {}", pet_dir.display());
}
Ok(pet_dir.join(path))
}
fn validate_app_spritesheet_dimensions(path: &Path) -> Result<()> {
let (width, height) =
image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?;
if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT {
bail!(
"spritesheet must be {}x{} pixels",
catalog::SPRITESHEET_WIDTH,
catalog::SPRITESHEET_HEIGHT
);
}
Ok(())
}
fn custom_pet_cache_id(id: &str) -> String {
format!("custom-{id}")
}
fn path_like(value: &str) -> bool {
@@ -277,8 +371,6 @@ fn idle_animation() -> Animation {
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
fn write_minimal_pet() -> tempfile::TempDir {
@@ -293,18 +385,40 @@ mod tests {
}"#,
)
.unwrap();
fs::File::create(dir.path().join("spritesheet.webp"))
.unwrap()
.write_all(b"not-used-by-loader")
.unwrap();
fs::copy(
catalog::builtin_spritesheet_path("codex-spritesheet-v3.webp"),
dir.path().join("spritesheet.webp"),
)
.unwrap();
dir
}
#[test]
fn load_pet_directory_uses_installed_pet_defaults() {
fn load_builtin_pet_uses_app_catalog_storage() {
let codex_home = tempfile::tempdir().unwrap();
let pet =
Pet::load_with_codex_home("dewey", /*codex_home*/ Some(codex_home.path())).unwrap();
assert_eq!(pet.id, "dewey");
assert_eq!(pet.display_name, "Dewey");
assert_eq!(pet.description, "A tidy duck for calm workspace days.");
assert_eq!(
pet.spritesheet_path,
catalog::builtin_spritesheet_path("dewey-spritesheet-v3.webp")
);
assert_eq!(pet.frame_width, 192);
assert_eq!(pet.frame_height, 208);
assert_eq!(pet.columns, 8);
assert_eq!(pet.rows, 9);
}
#[test]
fn load_pet_directory_uses_app_pet_manifest_defaults() {
let dir = write_minimal_pet();
let pet = Pet::load(dir.path().to_str().unwrap()).unwrap();
let pet =
Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap();
assert_eq!(pet.id, "chefito");
assert_eq!(pet.display_name, "Chefito");
@@ -319,14 +433,18 @@ mod tests {
fn load_pet_json_path_uses_containing_directory() {
let dir = write_minimal_pet();
let pet = Pet::load(dir.path().join("pet.json").to_str().unwrap()).unwrap();
let pet = Pet::load_with_codex_home(
dir.path().join("pet.json").to_str().unwrap(),
/*codex_home*/ None,
)
.unwrap();
let expected = dir.path().join("spritesheet.webp").canonicalize().unwrap();
assert_eq!(pet.spritesheet_path, expected);
}
#[test]
fn named_pet_prefers_codex_home_installation() {
fn custom_pet_selector_loads_codex_home_pet_manifest() {
let dir = write_minimal_pet();
let codex_home = tempfile::tempdir().unwrap();
let pet_dir = codex_home.path().join("pets").join("chefito");
@@ -338,9 +456,62 @@ mod tests {
)
.unwrap();
let pet = Pet::load_with_codex_home("chefito", Some(codex_home.path())).unwrap();
let pet = Pet::load_with_codex_home(
&custom_pet_selector("chefito"),
/*codex_home*/ Some(codex_home.path()),
)
.unwrap();
assert_eq!(pet.id, "chefito");
assert_eq!(pet.id, "custom-chefito");
assert_eq!(pet.spritesheet_path, pet_dir.join("spritesheet.webp"),);
}
#[test]
fn custom_pet_selector_falls_back_to_legacy_avatar_manifest() {
let dir = write_minimal_pet();
let codex_home = tempfile::tempdir().unwrap();
let avatar_dir = codex_home.path().join("avatars").join("legacy");
fs::create_dir_all(&avatar_dir).unwrap();
fs::copy(dir.path().join("pet.json"), avatar_dir.join("avatar.json")).unwrap();
fs::copy(
dir.path().join("spritesheet.webp"),
avatar_dir.join("spritesheet.webp"),
)
.unwrap();
let pet = Pet::load_with_codex_home(
&custom_pet_selector("legacy"),
/*codex_home*/ Some(codex_home.path()),
)
.unwrap();
assert_eq!(pet.id, "custom-legacy");
assert_eq!(pet.display_name, "Chefito");
}
#[test]
fn custom_pet_rejects_spritesheet_path_escape() {
let codex_home = tempfile::tempdir().unwrap();
let pet_dir = codex_home.path().join("pets").join("escape");
fs::create_dir_all(&pet_dir).unwrap();
fs::write(
pet_dir.join("pet.json"),
r#"{
"displayName": "Escape",
"spritesheetPath": "../spritesheet.webp"
}"#,
)
.unwrap();
let err = Pet::load_with_codex_home(
&custom_pet_selector("escape"),
/*codex_home*/ Some(codex_home.path()),
)
.unwrap_err();
assert!(
err.to_string()
.contains("spritesheet path must stay inside")
);
}
}

View File

@@ -1,5 +1,6 @@
//! Builds the /pets picker dialog for the TUI.
use std::collections::HashMap;
use std::fs;
use std::path::Path;
@@ -11,11 +12,15 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use super::DEFAULT_PET_ID;
use super::DISABLED_PET_ID;
use super::catalog;
use super::model::CUSTOM_PET_PREFIX;
use super::model::Pet;
use super::model::custom_pet_selector;
#[derive(Debug, Clone, PartialEq, Eq)]
struct PetPickerEntry {
selector: String,
legacy_selector: Option<String>,
display_name: String,
description: Option<String>,
}
@@ -33,7 +38,8 @@ pub(crate) fn build_pet_picker_params(
.into_iter()
.enumerate()
.map(|(idx, entry)| {
let is_current = current_pet == entry.selector;
let is_current = current_pet == entry.selector
|| entry.legacy_selector.as_deref() == Some(current_pet);
if is_current {
initial_selected_idx = Some(idx);
}
@@ -74,70 +80,65 @@ pub(crate) fn build_pet_picker_params(
}
fn available_pet_entries(codex_home: &Path) -> Vec<PetPickerEntry> {
let mut entries = vec![
pet_picker_entry(DEFAULT_PET_ID),
pet_picker_entry("boba"),
PetPickerEntry {
selector: DISABLED_PET_ID.to_string(),
display_name: "None".to_string(),
description: Some("Disable terminal pets.".to_string()),
},
];
let pets_dir = codex_home.join("pets");
let Ok(children) = fs::read_dir(pets_dir) else {
return entries;
};
for child in children.flatten() {
let path = child.path();
if !path.join("pet.json").is_file() {
continue;
}
let Some(selector) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if selector == DISABLED_PET_ID {
continue;
}
entries.push(pet_picker_entry_from_path(selector, &path));
}
let mut entries = catalog::BUILTIN_PETS
.iter()
.map(|pet| PetPickerEntry {
selector: pet.id.to_string(),
legacy_selector: None,
display_name: pet.display_name.to_string(),
description: Some(pet.description.to_string()),
})
.collect::<Vec<_>>();
entries.push(PetPickerEntry {
selector: DISABLED_PET_ID.to_string(),
legacy_selector: None,
display_name: "None".to_string(),
description: Some("Disable terminal pets.".to_string()),
});
entries.extend(custom_pet_entries(codex_home));
entries
}
fn pet_picker_entry(selector: &str) -> PetPickerEntry {
match Pet::load(selector) {
Ok(pet) => PetPickerEntry {
selector: selector.to_string(),
display_name: pet.display_name,
description: (!pet.description.is_empty()).then_some(pet.description),
},
Err(_) => PetPickerEntry {
selector: selector.to_string(),
display_name: selector.to_string(),
description: None,
},
fn custom_pet_entries(codex_home: &Path) -> Vec<PetPickerEntry> {
let mut entries_by_selector = HashMap::new();
for (directory_name, manifest_file) in [("avatars", "avatar.json"), ("pets", "pet.json")] {
let Ok(children) = fs::read_dir(codex_home.join(directory_name)) else {
continue;
};
for child in children.flatten() {
let path = child.path();
if !path.join(manifest_file).is_file() {
continue;
}
let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if id == DISABLED_PET_ID || id.starts_with(CUSTOM_PET_PREFIX) {
continue;
}
let selector = custom_pet_selector(id);
let Ok(pet) =
Pet::load_with_codex_home(&selector, /*codex_home*/ Some(codex_home))
else {
continue;
};
entries_by_selector.insert(
selector.clone(),
PetPickerEntry {
selector,
legacy_selector: Some(id.to_string()),
display_name: pet.display_name,
description: (!pet.description.is_empty()).then_some(pet.description),
},
);
}
}
}
fn pet_picker_entry_from_path(selector: &str, path: &Path) -> PetPickerEntry {
match Pet::load(path.to_string_lossy().as_ref()) {
Ok(pet) => PetPickerEntry {
selector: selector.to_string(),
display_name: pet.display_name,
description: (!pet.description.is_empty()).then_some(pet.description),
},
Err(_) => PetPickerEntry {
selector: selector.to_string(),
display_name: selector.to_string(),
description: None,
},
}
entries_by_selector.into_values().collect()
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
fn write_pet(dir: &Path, folder_name: &str, display_name: &str) {
@@ -155,14 +156,36 @@ mod tests {
),
)
.unwrap();
fs::File::create(pet_dir.join("spritesheet.webp"))
.unwrap()
.write_all(b"not-used-by-loader")
.unwrap();
fs::copy(
catalog::builtin_spritesheet_path("codex-spritesheet-v3.webp"),
pet_dir.join("spritesheet.webp"),
)
.unwrap();
}
fn write_legacy_avatar(dir: &Path, folder_name: &str, display_name: &str) {
let avatar_dir = dir.join("avatars").join(folder_name);
fs::create_dir_all(&avatar_dir).unwrap();
fs::write(
avatar_dir.join("avatar.json"),
format!(
r#"{{
"displayName": "{display_name}",
"description": "legacy custom pet",
"spritesheetPath": "spritesheet.webp"
}}"#
),
)
.unwrap();
fs::copy(
catalog::builtin_spritesheet_path("codex-spritesheet-v3.webp"),
avatar_dir.join("spritesheet.webp"),
)
.unwrap();
}
#[test]
fn picker_lists_bundled_and_installed_pets() {
fn picker_lists_app_bundled_and_custom_pets() {
let codex_home = tempfile::tempdir().unwrap();
write_pet(codex_home.path(), "chefito", "Chefito");
@@ -174,9 +197,24 @@ mod tests {
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>(),
vec!["Boba", "Chefito", "Codex", "None"],
vec![
"BSOD",
"Chefito",
"Codex",
"Dewey",
"Fireball",
"None",
"Null Signal",
"Rocky",
"Seedy",
"Stacky",
],
);
assert_eq!(params.initial_selected_idx, Some(1));
assert_eq!(
params.items[1].search_value.as_deref(),
Some("custom:chefito")
);
}
#[test]
@@ -194,8 +232,24 @@ mod tests {
let codex_home = tempfile::tempdir().unwrap();
let params = build_pet_picker_params(Some(DISABLED_PET_ID), codex_home.path());
assert_eq!(params.initial_selected_idx, Some(2));
assert_eq!(params.items[2].name, "None");
assert!(params.items[2].is_current);
assert_eq!(params.initial_selected_idx, Some(4));
assert_eq!(params.items[4].name, "None");
assert!(params.items[4].is_current);
}
#[test]
fn picker_imports_legacy_avatar_manifests() {
let codex_home = tempfile::tempdir().unwrap();
write_legacy_avatar(codex_home.path(), "legacy", "Legacy");
let params = build_pet_picker_params(Some("custom:legacy"), codex_home.path());
let legacy = params
.items
.iter()
.find(|item| item.name == "Legacy")
.unwrap();
assert!(legacy.is_current);
assert_eq!(legacy.search_value.as_deref(), Some("custom:legacy"));
}
}