use super::cell::{cellsym_block, decode_pua};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::OnceLock;
pub const PIXEL_LAYERED_SYMBOL_MAP_FILE: &str = "assets/pix/layered_symbol_map.json";
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct MipUV {
pub layer: u16,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Tile {
pub cell_w: u8,
pub cell_h: u8,
pub is_emoji: bool,
pub mips: [MipUV; 3],
}
const DEFAULT_TILE: Tile = Tile {
cell_w: 1,
cell_h: 1,
is_emoji: false,
mips: [MipUV { layer: 0, x: 0.0, y: 0.0, w: 0.0, h: 0.0 }; 3],
};
pub struct LayeredSymbolMap {
pub layer_size: u32,
pub layer_count: u32,
pub cell_pixel_size: u32,
pub layer_files: Vec<String>,
symbols: HashMap<String, Tile>,
reverse: HashMap<String, (u8, u8)>,
petscii: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct LayeredSymbolMapStats {
pub layer_size: u32,
pub layer_count: u32,
pub symbol_count: usize,
}
static GLOBAL_LAYERED_SYMBOL_MAP: OnceLock<LayeredSymbolMap> = OnceLock::new();
pub fn init_layered_symbol_map_from_json(json: &str) -> Result<(), String> {
let map = LayeredSymbolMap::from_json(json)?;
GLOBAL_LAYERED_SYMBOL_MAP
.set(map)
.map_err(|_| "Layered symbol map already initialized".to_string())
}
#[cfg(all(not(target_arch = "wasm32"), graphics_mode))]
pub fn init_layered_symbol_map_from_file(path: &str) -> Result<(), String> {
let map = LayeredSymbolMap::load(path)?;
GLOBAL_LAYERED_SYMBOL_MAP
.set(map)
.map_err(|_| "Layered symbol map already initialized".to_string())?;
log::info!("Loaded layered symbol map from {}", path);
Ok(())
}
pub fn get_layered_symbol_map() -> Option<&'static LayeredSymbolMap> {
GLOBAL_LAYERED_SYMBOL_MAP.get()
}
pub fn has_layered_symbol_map() -> bool {
GLOBAL_LAYERED_SYMBOL_MAP.get().is_some()
}
fn build_petscii_map() -> HashMap<String, String> {
let sprite_symbols: &str = "@abcdefghijklmnopqrstuvwxyz[£]↑← !\"#$%&'()*+,-./0123456789:;<=>?─ABCDEFGHIJKLMNOPQRSTUVWXYZ┼";
let mut map = HashMap::new();
for (idx, ch) in sprite_symbols.chars().enumerate() {
let block = (idx / 256) as u8;
let i = (idx % 256) as u8;
map.insert(ch.to_string(), cellsym_block(block, i));
}
let extras: &[(&str, u8, u8)] = &[
("▇", 1, 209), ("▒", 1, 94), ("∙", 1, 122), ("│", 1, 93), ("┐", 1, 110), ("╮", 1, 73), ("┌", 1, 112), ("╭", 1, 85), ("└", 1, 109), ("╰", 1, 74), ("┘", 1, 125), ("╯", 1, 75), ("_", 2, 30), ];
for &(sym, block, idx) in extras {
map.insert(sym.to_string(), cellsym_block(block, idx));
}
map
}
impl LayeredSymbolMap {
pub fn load(path: &str) -> Result<Self, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path, e))?;
Self::from_json(&content)
}
pub fn from_json(json: &str) -> Result<Self, String> {
let root: Value = serde_json::from_str(json)
.map_err(|e| format!("JSON parse error: {}", e))?;
let version = root["version"].as_u64().unwrap_or(0) as u32;
if version != 2 {
return Err(format!("Unsupported layered symbol map version: {} (expected 2)", version));
}
let layer_size = root["layer_size"].as_u64()
.ok_or("Missing layer_size")? as u32;
let layer_count = root["layer_count"].as_u64()
.ok_or("Missing layer_count")? as u32;
let cell_pixel_size = root["cell_pixel_size"].as_u64().unwrap_or(32) as u32;
let layer_files: Vec<String> = root["layer_files"]
.as_array()
.ok_or("Missing layer_files")?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if layer_files.len() != layer_count as usize {
return Err(format!(
"layer_count ({}) != layer_files length ({})",
layer_count, layer_files.len()
));
}
let symbols_obj = root["symbols"]
.as_object()
.ok_or("Missing symbols")?;
let inv = 1.0 / layer_size as f32;
let mut symbols = HashMap::with_capacity(symbols_obj.len());
let mut reverse = HashMap::with_capacity(symbols_obj.len());
let mut tui_count: u16 = 0;
let mut emoji_count: u16 = 0;
let mut cjk_count: u16 = 0;
for (key, val) in symbols_obj {
let arr = val.as_array().ok_or_else(|| format!("Symbol '{}' is not an array", key))?;
if arr.len() < 17 {
return Err(format!("Symbol '{}' array too short: {} < 17", key, arr.len()));
}
let cell_w = arr[0].as_u64().unwrap_or(1) as u8;
let cell_h = arr[1].as_u64().unwrap_or(1) as u8;
let mut mips = [MipUV::default(); 3];
for i in 0..3 {
let base = 2 + i * 5;
mips[i] = MipUV {
layer: arr[base].as_u64().unwrap_or(0) as u16,
x: arr[base + 1].as_u64().unwrap_or(0) as f32 * inv,
y: arr[base + 2].as_u64().unwrap_or(0) as f32 * inv,
w: arr[base + 3].as_u64().unwrap_or(0) as f32 * inv,
h: arr[base + 4].as_u64().unwrap_or(0) as f32 * inv,
};
}
let is_emoji = key.chars().next().map_or(false, |ch| {
let cp = ch as u32;
(0x1F000..=0x1FAFF).contains(&cp)
|| (0x2300..=0x23FF).contains(&cp)
|| (0x2600..=0x26FF).contains(&cp)
|| (0x2700..=0x27BF).contains(&cp)
|| (0x2B00..=0x2BFF).contains(&cp)
});
symbols.insert(key.clone(), Tile { cell_w, cell_h, is_emoji, mips });
if let Some(ch) = key.chars().next() {
if let Some((block, idx)) = decode_pua(ch) {
reverse.insert(key.clone(), (block, idx));
} else {
let cp = ch as u32;
let is_emoji_cp = (0x1F000..=0x1FAFF).contains(&cp)
|| (0x2300..=0x23FF).contains(&cp)
|| (0x2600..=0x26FF).contains(&cp)
|| (0x2700..=0x27BF).contains(&cp)
|| (0x2B00..=0x2BFF).contains(&cp);
if is_emoji_cp {
let block = 170 + (emoji_count / 128) as u8;
let idx = (emoji_count % 128) as u8;
reverse.insert(key.clone(), (block, idx));
emoji_count += 1;
} else if (0x4E00..=0x9FFF).contains(&cp) {
let block = 176 + (cjk_count / 64) as u8;
let idx = (cjk_count % 64) as u8;
reverse.insert(key.clone(), (block, idx));
cjk_count += 1;
} else {
let block = 160 + (tui_count / 256) as u8;
let idx = (tui_count % 256) as u8;
reverse.insert(key.clone(), (block, idx));
tui_count += 1;
}
}
}
}
Ok(Self {
layer_size,
layer_count,
cell_pixel_size,
layer_files,
symbols,
reverse,
petscii: build_petscii_map(),
})
}
#[inline]
pub fn resolve(&self, symbol: &str) -> &Tile {
self.symbols.get(symbol).unwrap_or(&DEFAULT_TILE)
}
#[inline]
pub fn contains(&self, symbol: &str) -> bool {
self.symbols.contains_key(symbol)
}
#[inline]
pub fn reverse_lookup(&self, symbol: &str) -> Option<(u8, u8)> {
self.reverse.get(symbol).copied()
}
#[inline]
pub fn petscii_lookup(&self, symbol: &str) -> Option<&str> {
self.petscii.get(symbol).map(|s| s.as_str())
}
pub fn symbol_count(&self) -> usize {
self.symbols.len()
}
pub fn stats(&self) -> LayeredSymbolMapStats {
LayeredSymbolMapStats {
layer_size: self.layer_size,
layer_count: self.layer_count,
symbol_count: self.symbols.len(),
}
}
}
pub fn ascii_to_petscii(s: &str) -> String {
if let Some(map) = get_layered_symbol_map() {
let mut result = String::with_capacity(s.len() * 4);
for ch in s.chars() {
let ch_str = ch.to_string();
if let Some(pua) = map.petscii_lookup(&ch_str) {
result.push_str(pua);
} else {
result.push_str(&cellsym_block(0, ch as u8));
}
}
result
} else {
s.chars()
.map(|ch| cellsym_block(0, ch as u8))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_layered_json() -> String {
let pua_char = '\u{F0000}'; let emoji_char = '\u{1F600}'; format!(
r#"{{
"version": 2,
"layer_size": 2048,
"layer_count": 2,
"layer_files": ["layers/layer_0.png", "layers/layer_1.png"],
"symbols": {{
"A": {{
"w": 1, "h": 2,
"mip0": {{"layer": 0, "x": 64, "y": 0, "w": 64, "h": 128}},
"mip1": {{"layer": 0, "x": 1024, "y": 1024, "w": 32, "h": 64}},
"mip2": {{"layer": 1, "x": 0, "y": 0, "w": 16, "h": 32}}
}},
"{}": {{
"w": 1, "h": 1,
"mip0": {{"layer": 0, "x": 0, "y": 128, "w": 64, "h": 64}},
"mip1": {{"layer": 0, "x": 1024, "y": 1088, "w": 32, "h": 32}},
"mip2": {{"layer": 1, "x": 16, "y": 0, "w": 16, "h": 16}}
}},
"{}": {{
"w": 2, "h": 2,
"mip0": {{"layer": 0, "x": 0, "y": 192, "w": 128, "h": 128}},
"mip1": {{"layer": 0, "x": 1024, "y": 1120, "w": 64, "h": 64}},
"mip2": {{"layer": 1, "x": 32, "y": 0, "w": 32, "h": 32}}
}},
"中": {{
"w": 2, "h": 2,
"mip0": {{"layer": 0, "x": 128, "y": 192, "w": 128, "h": 128}},
"mip1": {{"layer": 0, "x": 1088, "y": 1120, "w": 64, "h": 64}},
"mip2": {{"layer": 1, "x": 64, "y": 0, "w": 32, "h": 32}}
}}
}}
}}"#,
pua_char, emoji_char
)
}
#[test]
fn test_layered_parse_basic() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
assert_eq!(map.layer_size, 2048);
assert_eq!(map.layer_count, 2);
assert_eq!(map.layer_files.len(), 2);
assert_eq!(map.symbol_count(), 4);
}
#[test]
fn test_layered_resolve_tui() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let tile = map.resolve("A");
assert_eq!(tile.cell_w, 1);
assert_eq!(tile.cell_h, 2);
assert_eq!(tile.mips[0].layer, 0);
assert!((tile.mips[0].x - 64.0 / 2048.0).abs() < 1e-6);
assert!((tile.mips[0].y - 0.0).abs() < 1e-6);
assert!((tile.mips[0].w - 64.0 / 2048.0).abs() < 1e-6);
assert!((tile.mips[0].h - 128.0 / 2048.0).abs() < 1e-6);
}
#[test]
fn test_layered_resolve_sprite_pua() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let pua = "\u{F0000}";
let tile = map.resolve(pua);
assert_eq!(tile.cell_w, 1);
assert_eq!(tile.cell_h, 1);
assert_eq!(tile.mips[0].layer, 0);
assert_eq!(tile.mips[2].layer, 1);
}
#[test]
fn test_layered_resolve_emoji() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let tile = map.resolve("\u{1F600}");
assert_eq!(tile.cell_w, 2);
assert_eq!(tile.cell_h, 2);
assert!((tile.mips[0].w - 128.0 / 2048.0).abs() < 1e-6);
assert!((tile.mips[0].h - 128.0 / 2048.0).abs() < 1e-6);
}
#[test]
fn test_layered_resolve_cjk() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let tile = map.resolve("中");
assert_eq!(tile.cell_w, 2);
assert_eq!(tile.cell_h, 2);
assert_eq!(tile.mips[1].layer, 0);
assert!((tile.mips[1].w - 64.0 / 2048.0).abs() < 1e-6);
}
#[test]
fn test_layered_resolve_unknown() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let tile = map.resolve("NONEXISTENT");
assert_eq!(tile.cell_w, 1);
assert_eq!(tile.cell_h, 1);
assert!((tile.mips[0].w).abs() < 1e-6); }
#[test]
fn test_layered_uv_range() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
for tile in map.symbols.values() {
for mip in &tile.mips {
assert!(mip.x >= 0.0 && mip.x <= 1.0, "x out of range: {}", mip.x);
assert!(mip.y >= 0.0 && mip.y <= 1.0, "y out of range: {}", mip.y);
assert!(mip.w >= 0.0 && mip.w <= 1.0, "w out of range: {}", mip.w);
assert!(mip.h >= 0.0 && mip.h <= 1.0, "h out of range: {}", mip.h);
assert!(mip.x + mip.w <= 1.0 + 1e-6, "x+w out of range");
assert!(mip.y + mip.h <= 1.0 + 1e-6, "y+h out of range");
}
}
}
#[test]
fn test_layered_layer_bounds() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
for tile in map.symbols.values() {
for mip in &tile.mips {
assert!(
(mip.layer as u32) < map.layer_count,
"layer {} >= layer_count {}",
mip.layer, map.layer_count
);
}
}
}
#[test]
fn test_layered_version_check() {
let bad_json = r#"{"version": 1, "layer_size": 2048, "layer_count": 0, "layer_files": [], "symbols": {}}"#;
assert!(LayeredSymbolMap::from_json(bad_json).is_err());
}
#[test]
fn test_layered_stats() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let stats = map.stats();
assert_eq!(stats.layer_size, 2048);
assert_eq!(stats.layer_count, 2);
assert_eq!(stats.symbol_count, 4);
}
#[test]
fn test_tile_size() {
assert!(std::mem::size_of::<Tile>() <= 64, "Tile too large: {} bytes", std::mem::size_of::<Tile>());
let m = MipUV::default();
let m2 = m; assert_eq!(m2.layer, 0);
}
#[test]
fn test_reverse_lookup_pua() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let pua = "\u{F0000}";
assert_eq!(map.reverse_lookup(pua), Some((0, 0)));
}
#[test]
fn test_reverse_lookup_tui() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let result = map.reverse_lookup("A");
assert!(result.is_some());
let (block, _idx) = result.unwrap();
assert!(block >= 160 && block < 170, "TUI block should be 160-169, got {}", block);
}
#[test]
fn test_reverse_lookup_emoji() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let result = map.reverse_lookup("\u{1F600}");
assert!(result.is_some());
let (block, _idx) = result.unwrap();
assert!(block >= 170 && block < 176, "Emoji block should be 170-175, got {}", block);
}
#[test]
fn test_reverse_lookup_cjk() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let result = map.reverse_lookup("中");
assert!(result.is_some());
let (block, _idx) = result.unwrap();
assert!(block >= 176, "CJK block should be >= 176, got {}", block);
}
#[test]
fn test_reverse_lookup_unknown() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
assert_eq!(map.reverse_lookup("NONEXISTENT"), None);
}
#[test]
fn test_petscii_lookup_letters() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
let pua_a = map.petscii_lookup("A");
assert!(pua_a.is_some(), "A should have PETSCII mapping");
let pua_a = pua_a.unwrap();
assert_eq!(pua_a, &cellsym_block(0, 65));
assert_eq!(map.petscii_lookup("P").unwrap(), &cellsym_block(0, 80));
assert_eq!(map.petscii_lookup("0").unwrap(), &cellsym_block(0, 48));
}
#[test]
fn test_petscii_lookup_extras() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
assert_eq!(
map.petscii_lookup("\u{2502}").unwrap(),
&cellsym_block(1, 93),
);
assert_eq!(
map.petscii_lookup("\u{250C}").unwrap(),
&cellsym_block(1, 112),
);
assert_eq!(
map.petscii_lookup("\u{256D}").unwrap(),
&cellsym_block(1, 85),
);
assert_eq!(
map.petscii_lookup("_").unwrap(),
&cellsym_block(2, 30),
);
}
#[test]
fn test_petscii_lookup_nonexistent() {
let map = LayeredSymbolMap::from_json(&sample_layered_json()).unwrap();
assert!(map.petscii_lookup("\u{1F600}").is_none()); assert!(map.petscii_lookup("\u{4E2D}").is_none()); }
}