use crate::app::App;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const INTEGRATION_RANGE_START: u32 = 0xF1C00;
const INTEGRATION_RANGE_END: u32 = 0xF1CFF;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Assignment {
id: String,
codepoint: String,
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct AssignmentFile {
#[serde(default, rename = "assignment")]
entries: Vec<Assignment>,
}
fn pending_glyphs_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(
PathBuf::from(home)
.join(".cache")
.join("mnml")
.join("pending-glyphs"),
)
}
fn assignments_path() -> Option<PathBuf> {
Some(crate::data_root::data_root().join("integration-glyphs.toml"))
}
fn legacy_assignments_paths() -> Vec<PathBuf> {
let root = crate::data_root::data_root();
vec![
root.join("sibling-glyphs.toml"),
root.join("glyphs").join("assignments.toml"),
]
}
fn load_assignments() -> AssignmentFile {
let Some(p) = assignments_path() else {
return AssignmentFile::default();
};
if !p.exists() {
for legacy in legacy_assignments_paths() {
if !legacy.exists() {
continue;
}
if let Some(parent) = p.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::rename(&legacy, &p).is_err()
&& let Ok(text) = std::fs::read_to_string(&legacy)
{
let _ = std::fs::write(&p, &text);
let _ = std::fs::remove_file(&legacy);
}
break;
}
}
let Ok(text) = std::fs::read_to_string(&p) else {
return AssignmentFile::default();
};
toml::from_str(&text).unwrap_or_default()
}
fn save_assignments(file: &AssignmentFile) {
let Some(p) = assignments_path() else {
return;
};
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let deduped = dedupe_aliases(file);
if let Ok(text) = toml::to_string_pretty(&deduped) {
let _ = crate::app::backup::write_toml_with_backup(&p, &text, "assignments");
}
}
fn is_alias_pair(short: &str, long: &str) -> bool {
if short.is_empty() || long.len() <= short.len() {
return false;
}
long.strip_prefix("mnml-")
.and_then(|rest| rest.rsplit_once('-'))
.is_some_and(|(_family, tail)| tail.eq_ignore_ascii_case(short))
}
fn dedupe_aliases(file: &AssignmentFile) -> AssignmentFile {
use std::collections::HashMap;
let mut by_cp: HashMap<String, Vec<&Assignment>> = HashMap::new();
for e in &file.entries {
by_cp.entry(e.codepoint.clone()).or_default().push(e);
}
let mut keep: Vec<Assignment> = Vec::with_capacity(file.entries.len());
for group in by_cp.values() {
if group.len() < 2 {
keep.push((*group[0]).clone());
continue;
}
let mut kept_ids: Vec<&Assignment> = Vec::new();
for candidate in group {
let overshadowed = group
.iter()
.any(|other| other.id != candidate.id && is_alias_pair(&candidate.id, &other.id));
if !overshadowed {
kept_ids.push(candidate);
}
}
for e in kept_ids {
keep.push((*e).clone());
}
}
keep.sort_by(|a, b| a.id.cmp(&b.id));
AssignmentFile { entries: keep }
}
pub(crate) fn purge_integration_glyph_state(id: &str) -> (bool, bool) {
let svg_deleted = pending_glyphs_dir()
.map(|d| d.join(format!("{id}.svg")))
.is_some_and(|p| p.exists() && std::fs::remove_file(&p).is_ok());
let mut file = load_assignments();
let cp_hex = file
.entries
.iter()
.find(|e| e.id == id)
.map(|e| e.codepoint.clone());
let before = file.entries.len();
file.entries.retain(|e| e.id != id);
let assignment_dropped = file.entries.len() != before;
if assignment_dropped {
save_assignments(&file);
}
if let Some(hex) = cp_hex {
let _ = crate::glyph_builder::remove_meta_by_cp_hex(&hex);
}
(svg_deleted, assignment_dropped)
}
pub(crate) fn discover(
dir: &Path,
manifest_overrides: &HashMap<String, u32>,
) -> (Vec<(String, PathBuf)>, HashMap<String, u32>) {
let mut svgs: Vec<(String, PathBuf)> = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return (svgs, HashMap::new()),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("svg") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if stem.is_empty() || stem.contains(['/', '\\', '\0']) {
continue;
}
svgs.push((stem.to_string(), path));
}
svgs.sort_by(|a, b| a.0.cmp(&b.0));
let mut file = load_assignments();
let mut used: std::collections::HashSet<u32> = file
.entries
.iter()
.filter_map(|e| u32::from_str_radix(&e.codepoint, 16).ok())
.collect();
for (id, cp) in manifest_overrides {
file.entries.retain(|e| &e.id != id);
file.entries.push(Assignment {
id: id.clone(),
codepoint: format!("{cp:04X}"),
});
used.insert(*cp);
}
let mut out: HashMap<String, u32> = file
.entries
.iter()
.filter_map(|e| {
u32::from_str_radix(&e.codepoint, 16)
.ok()
.map(|cp| (e.id.clone(), cp))
})
.collect();
for (id, _path) in &svgs {
if let Some(entry) = file.entries.iter().find(|e| &e.id == id)
&& let Ok(cp) = u32::from_str_radix(&entry.codepoint, 16)
{
out.insert(id.clone(), cp);
continue;
}
let mut assigned: Option<u32> = None;
for cp in INTEGRATION_RANGE_START..=INTEGRATION_RANGE_END {
if !used.contains(&cp) {
used.insert(cp);
assigned = Some(cp);
break;
}
}
let Some(cp) = assigned else {
eprintln!(
"mnml: integration glyph range U+{:04X}-U+{:04X} exhausted; \
dropping {id}",
INTEGRATION_RANGE_START, INTEGRATION_RANGE_END
);
continue;
};
file.entries.push(Assignment {
id: id.clone(),
codepoint: format!("{cp:04X}"),
});
out.insert(id.clone(), cp);
}
file.entries.sort_by(|a, b| a.id.cmp(&b.id));
save_assignments(&file);
(svgs, out)
}
impl App {
pub fn stage_terminal_glyph_svg(&mut self) {
let raw = self.config.ui.terminal_glyph_svg.trim();
if raw.is_empty() {
return;
}
let path = if let Some(rest) = raw.strip_prefix("~/") {
let Some(home) = std::env::var_os("HOME") else {
return;
};
std::path::PathBuf::from(home).join(rest)
} else {
std::path::PathBuf::from(raw)
};
if !path.exists() {
return;
}
let Some(pending) = std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h)
.join(".cache")
.join("mnml")
.join("pending-glyphs")
}) else {
return;
};
let _ = std::fs::create_dir_all(&pending);
let dst = pending.join("terminal.svg");
let Some(home) = std::env::var_os("HOME") else {
return;
};
let font = std::path::PathBuf::from(&home).join("Library/Fonts/MnmlSymbols.ttf");
let src_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
let font_mtime = std::fs::metadata(&font).and_then(|m| m.modified()).ok();
if let (Some(s), Some(f)) = (src_mtime, font_mtime)
&& s <= f
{
return;
}
let _ = std::fs::copy(&path, &dst);
}
pub fn discover_integration_glyphs(&mut self) {
let mut overrides: HashMap<String, u32> = HashMap::new();
for m in &self.integration_manifests {
let Some(chip) = &m.chip else { continue };
let Some(cp_hex) = &chip.glyph_codepoint else {
continue;
};
if let Ok(cp) = u32::from_str_radix(cp_hex.trim_start_matches("U+"), 16) {
overrides.insert(m.id.clone(), cp);
}
}
let Some(pending) = pending_glyphs_dir() else {
return;
};
let (svgs, assignments) = discover(&pending, &overrides);
self.integration_glyph_svgs = svgs;
self.integration_glyph_codepoints = assignments;
self.reconcile_glyph_codepoints_from_meta();
}
fn reconcile_glyph_codepoints_from_meta(&mut self) {
let meta = crate::glyph_builder::load_meta();
let mut assignments = load_assignments();
let mut changed = false;
for entry in &meta.glyphs {
let Ok(cp) = u32::from_str_radix(&entry.codepoint, 16) else {
continue;
};
let Some(id) = entry.name.strip_prefix("sibling-") else {
continue;
};
let previous = self.integration_glyph_codepoints.get(id).copied();
if previous != Some(cp) {
self.integration_glyph_codepoints.insert(id.to_string(), cp);
changed = true;
}
if let Some(slot) = assignments.entries.iter_mut().find(|e| e.id == id) {
if slot.codepoint != entry.codepoint {
slot.codepoint = entry.codepoint.clone();
changed = true;
}
} else {
assignments.entries.push(Assignment {
id: id.to_string(),
codepoint: entry.codepoint.clone(),
});
changed = true;
}
}
if changed {
save_assignments(&assignments);
}
}
pub fn purge_baked_pending_glyphs(&self) -> usize {
let Some(pending) = pending_glyphs_dir() else {
return 0;
};
if !pending.is_dir() {
return 0;
}
let Some(home) = std::env::var_os("HOME") else {
return 0;
};
let font = std::path::PathBuf::from(home).join("Library/Fonts/MnmlSymbols.ttf");
let Ok(font_meta) = std::fs::metadata(&font) else {
return 0;
};
let Ok(font_mtime) = font_meta.modified() else {
return 0;
};
let mut deleted = 0usize;
for (id, _) in &self.integration_glyph_svgs {
let candidate = pending.join(format!("{id}.svg"));
let Ok(svg_meta) = std::fs::metadata(&candidate) else {
continue;
};
let Ok(svg_mtime) = svg_meta.modified() else {
continue;
};
if font_mtime > svg_mtime && std::fs::remove_file(&candidate).is_ok() {
deleted += 1;
}
}
deleted
}
pub fn bake_integration_glyphs(&mut self) {
if self.integration_glyph_svgs.is_empty() {
self.toast("bake integration glyphs: no SVGs in ~/.cache/mnml/pending-glyphs/");
return;
}
let Some(home) = std::env::var_os("HOME") else {
self.toast("bake integration glyphs: $HOME unset");
return;
};
let home = PathBuf::from(home);
let font_out = home.join("Library/Fonts/MnmlSymbols.ttf");
let script = match std::env::current_exe()
.ok()
.and_then(|p| {
let mut cur = p;
while cur.pop() {
let cand = cur.join("scripts/build_mnml_symbols.py");
if cand.exists() {
return Some(cand);
}
}
None
})
.or_else(|| {
let cand = home.join("Projects/mnml/scripts/build_mnml_symbols.py");
if cand.exists() { Some(cand) } else { None }
}) {
Some(p) => p,
None => {
self.toast("bake integration glyphs: build_mnml_symbols.py not found");
return;
}
};
let mut args: Vec<String> = vec![
"-script".to_string(),
script.to_string_lossy().into_owned(),
"--output".to_string(),
font_out.to_string_lossy().into_owned(),
];
let mut baked = 0usize;
for (id, svg_path) in &self.integration_glyph_svgs {
let Some(cp) = self.integration_glyph_codepoints.get(id).copied() else {
eprintln!("mnml: integration glyph {id} has no codepoint; skipping");
continue;
};
let (width_frac, height_frac, center_frac, center_x_frac) = if id == "terminal" {
(1.07f32, 0.76f32, 0.30f32, 0.50f32)
} else {
(1.25f32, 0.80f32, 0.36f32, 0.50f32)
};
args.push("--glyph".to_string());
args.push(format!(
"{}:{:04X}:sibling-{}:width={:.2}:height={:.2}:center={:.2}:x_center={:.2}",
svg_path.display(),
cp,
id,
width_frac,
height_frac,
center_frac,
center_x_frac,
));
crate::glyph_builder::upsert_meta(crate::glyph_builder::GlyphMeta {
codepoint: format!("{cp:04X}"),
name: format!("sibling-{id}"),
svg: svg_path.to_string_lossy().into_owned(),
width_frac,
height_frac,
center_frac,
center_x_frac,
});
baked += 1;
}
if baked == 0 {
self.toast("bake integration glyphs: nothing to bake (codepoints missing)");
return;
}
let profile = crate::pty_pane::BinaryProfile {
label: format!("bake integration glyphs ({baked})"),
exe: "fontforge".to_string(),
args,
cwd: None,
env: vec![],
session_id: None,
integration_id: None,
};
self.open_pty(profile);
self.toast(format!(
"baking {baked} integration glyph(s) · restart terminal after fontforge exits"
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_svg(dir: &Path, name: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, b"<svg/>").unwrap();
p
}
#[test]
fn is_alias_pair_matches_family_qualified_long_form() {
assert!(is_alias_pair("amplify", "mnml-aws-amplify"));
assert!(is_alias_pair("codebuild", "mnml-aws-codebuild"));
assert!(is_alias_pair("slack", "mnml-msg-slack"));
assert!(
is_alias_pair("AMPLIFY", "mnml-aws-amplify"),
"case-insensitive tail match"
);
}
#[test]
fn is_alias_pair_rejects_unrelated() {
assert!(!is_alias_pair("amplify", "mnml-aws-codebuild"));
assert!(!is_alias_pair("amplify", "amplify")); assert!(!is_alias_pair("aws", "mnml-aws-amplify")); assert!(!is_alias_pair("", "mnml-aws-amplify"));
}
#[test]
fn dedupe_drops_short_id_when_long_peer_shares_codepoint() {
let file = AssignmentFile {
entries: vec![
Assignment {
id: "amplify".into(),
codepoint: "F1C0E".into(),
},
Assignment {
id: "mnml-aws-amplify".into(),
codepoint: "F1C0E".into(),
},
Assignment {
id: "terminal".into(),
codepoint: "F1C14".into(),
},
],
};
let out = dedupe_aliases(&file);
assert_eq!(out.entries.len(), 2);
assert!(
out.entries
.iter()
.any(|e| e.id == "mnml-aws-amplify" && e.codepoint == "F1C0E")
);
assert!(out.entries.iter().any(|e| e.id == "terminal"));
assert!(
!out.entries.iter().any(|e| e.id == "amplify"),
"short alias dropped"
);
}
#[test]
fn dedupe_leaves_unrelated_dupes_alone() {
let file = AssignmentFile {
entries: vec![
Assignment {
id: "foo".into(),
codepoint: "F1C99".into(),
},
Assignment {
id: "bar".into(),
codepoint: "F1C99".into(),
},
],
};
let out = dedupe_aliases(&file);
assert_eq!(out.entries.len(), 2, "unrelated dupes preserved");
}
#[test]
fn assigns_deterministic_codepoints_by_sorted_id() {
let tmp = tempfile::tempdir().unwrap();
write_svg(tmp.path(), "beta.svg");
write_svg(tmp.path(), "alpha.svg");
write_svg(tmp.path(), "charlie.svg");
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let canonical = tmp.path().join(".config/mnml/glyphs");
fs::create_dir_all(&canonical).unwrap();
for n in ["alpha.svg", "beta.svg", "charlie.svg"] {
fs::copy(tmp.path().join(n), canonical.join(n)).unwrap();
}
let (svgs, assignments) = discover(&canonical, &HashMap::new());
assert_eq!(svgs.len(), 3);
assert_eq!(svgs[0].0, "alpha");
assert_eq!(svgs[1].0, "beta");
assert_eq!(svgs[2].0, "charlie");
assert_eq!(assignments["alpha"], INTEGRATION_RANGE_START);
assert_eq!(assignments["beta"], INTEGRATION_RANGE_START + 1);
assert_eq!(assignments["charlie"], INTEGRATION_RANGE_START + 2);
}
#[test]
fn manifest_override_pins_codepoint_outside_range() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let dir = tmp.path().join(".config/mnml/glyphs");
fs::create_dir_all(&dir).unwrap();
write_svg(&dir, "amplify.svg");
let mut overrides = HashMap::new();
overrides.insert("amplify".to_string(), 0xF1B00);
let (svgs, assignments) = discover(&dir, &overrides);
assert_eq!(svgs.len(), 1);
assert_eq!(assignments["amplify"], 0xF1B00);
}
#[test]
fn preserves_prior_assignment_across_calls() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _data_root = crate::EnvGuard::set("MNML_DATA_ROOT", tmp.path().join(".config/mnml"));
let dir = tmp.path().join(".cache/mnml/pending-glyphs");
fs::create_dir_all(&dir).unwrap();
write_svg(&dir, "one.svg");
let (_svgs, first) = discover(&dir, &HashMap::new());
let one_cp = first["one"];
write_svg(&dir, "aaa.svg");
let (_svgs, second) = discover(&dir, &HashMap::new());
assert_eq!(second["one"], one_cp, "prior assignment persisted");
assert_ne!(second["aaa"], one_cp);
}
#[test]
fn purge_integration_glyph_state_drops_svg_and_assignment_entry() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _data_root = crate::EnvGuard::set("MNML_DATA_ROOT", tmp.path().join(".config/mnml"));
let dir = tmp.path().join(".cache/mnml/pending-glyphs");
fs::create_dir_all(&dir).unwrap();
write_svg(&dir, "victim.svg");
write_svg(&dir, "keeper.svg");
let (_svgs, assignments) = discover(&dir, &HashMap::new());
assert!(dir.join("victim.svg").exists());
assert!(dir.join("keeper.svg").exists());
let assign_pre = load_assignments();
let ids_pre: Vec<&str> = assign_pre.entries.iter().map(|e| e.id.as_str()).collect();
assert!(
assign_pre.entries.iter().any(|e| e.id == "victim"),
"pre-purge: victim missing from assignments (in-memory={:?}, on-disk ids={:?}, data_root={:?})",
assignments.keys().collect::<Vec<_>>(),
ids_pre,
crate::data_root::data_root(),
);
assert!(
assign_pre.entries.iter().any(|e| e.id == "keeper"),
"pre-purge: keeper missing from assignments (in-memory={:?}, on-disk ids={:?}, data_root={:?})",
assignments.keys().collect::<Vec<_>>(),
ids_pre,
crate::data_root::data_root(),
);
let (svg_gone, assignment_gone) = purge_integration_glyph_state("victim");
assert!(svg_gone, "svg file was expected to exist + delete cleanly");
assert!(
assignment_gone,
"assignment entry was expected to exist + drop cleanly"
);
assert!(!dir.join("victim.svg").exists(), "svg deleted");
assert!(dir.join("keeper.svg").exists(), "keeper's svg untouched");
let assign_post = load_assignments();
let ids_post: Vec<&str> = assign_post.entries.iter().map(|e| e.id.as_str()).collect();
assert!(
!assign_post.entries.iter().any(|e| e.id == "victim"),
"post-purge: victim survived (on-disk ids={ids_post:?})",
);
assert!(
assign_post.entries.iter().any(|e| e.id == "keeper"),
"post-purge: keeper's assignment entry preserved (on-disk ids={ids_post:?})",
);
}
#[test]
fn purge_integration_glyph_state_drops_matching_glyph_meta_entry() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _data_root = crate::EnvGuard::set("MNML_DATA_ROOT", tmp.path().join(".config/mnml"));
let dir = tmp.path().join(".cache/mnml/pending-glyphs");
fs::create_dir_all(&dir).unwrap();
write_svg(&dir, "victim.svg");
write_svg(&dir, "keeper.svg");
let (_svgs, assigned) = discover(&dir, &HashMap::new());
let victim_cp = assigned["victim"];
let keeper_cp = assigned["keeper"];
crate::glyph_builder::upsert_meta(crate::glyph_builder::GlyphMeta {
codepoint: format!("{victim_cp:04X}"),
name: "victim".into(),
svg: "/tmp/victim.svg".into(),
width_frac: 1.0,
height_frac: 1.0,
center_frac: 0.5,
center_x_frac: 0.5,
});
crate::glyph_builder::upsert_meta(crate::glyph_builder::GlyphMeta {
codepoint: format!("{keeper_cp:04X}"),
name: "keeper".into(),
svg: "/tmp/keeper.svg".into(),
width_frac: 1.0,
height_frac: 1.0,
center_frac: 0.5,
center_x_frac: 0.5,
});
let meta_pre = crate::glyph_builder::load_meta();
assert_eq!(meta_pre.glyphs.len(), 2);
purge_integration_glyph_state("victim");
let meta_post = crate::glyph_builder::load_meta();
assert_eq!(meta_post.glyphs.len(), 1, "victim's meta entry dropped");
assert!(
meta_post
.glyphs
.iter()
.any(|g| g.codepoint == format!("{keeper_cp:04X}")),
"keeper's meta entry preserved"
);
}
#[test]
fn purge_integration_glyph_state_noop_when_id_never_registered() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _data_root = crate::EnvGuard::set("MNML_DATA_ROOT", tmp.path().join(".config/mnml"));
let (svg_gone, assignment_gone) = purge_integration_glyph_state("nonexistent");
assert!(!svg_gone);
assert!(!assignment_gone);
}
}