feat(tui): port Codex App pets
BIN
codex-rs/tui/pets/assets/bsod-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
codex-rs/tui/pets/assets/codex-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 987 KiB |
BIN
codex-rs/tui/pets/assets/dewey-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 992 KiB |
BIN
codex-rs/tui/pets/assets/fireball-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
codex-rs/tui/pets/assets/null-signal-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 585 KiB |
BIN
codex-rs/tui/pets/assets/rocky-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 761 KiB |
BIN
codex-rs/tui/pets/assets/seedy-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
codex-rs/tui/pets/assets/stacky-spritesheet-v3.webp
Normal file
|
After Width: | Height: | Size: 861 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 867 KiB |
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
81
codex-rs/tui/src/pets/catalog.rs
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||