use std::path::Path;
use resvg::tiny_skia::Pixmap;
use resvg::usvg::{Options, Transform, Tree};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuilderField {
Path,
Category,
Name,
Codepoint,
WidthFrac,
HeightFrac,
CenterFrac,
CenterXFrac,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuilderCategory {
Aws,
IntegrationSvg,
Azure,
Ai,
Saas,
DevTool,
}
impl BuilderCategory {
pub const ALL: &'static [BuilderCategory] = &[
BuilderCategory::Aws,
BuilderCategory::IntegrationSvg,
BuilderCategory::Azure,
BuilderCategory::Ai,
BuilderCategory::Saas,
BuilderCategory::DevTool,
];
pub fn label(self) -> &'static str {
match self {
BuilderCategory::Aws => "aws",
BuilderCategory::IntegrationSvg => "integration",
BuilderCategory::Azure => "azure",
BuilderCategory::Ai => "ai",
BuilderCategory::Saas => "saas",
BuilderCategory::DevTool => "dev",
}
}
pub fn range_start(self) -> u32 {
match self {
BuilderCategory::Aws => 0xF1B00,
BuilderCategory::IntegrationSvg => 0xF1C00,
BuilderCategory::Azure => 0xF1D00,
BuilderCategory::Ai => 0xF1E00,
BuilderCategory::Saas => 0xF1F00,
BuilderCategory::DevTool => 0xF2000,
}
}
pub fn range_end(self) -> u32 {
self.range_start() + 0xFF
}
pub fn cycled(self, delta: isize) -> Self {
let idx = Self::ALL.iter().position(|c| *c == self).unwrap_or(0) as isize;
let n = Self::ALL.len() as isize;
let next = (idx + delta).rem_euclid(n) as usize;
Self::ALL[next]
}
}
#[derive(Debug, Clone)]
pub struct GlyphBuilderState {
pub svg_path: String,
pub category: BuilderCategory,
pub name: String,
pub codepoint_hex: String,
pub width_frac: f32,
pub height_frac: f32,
pub center_frac: f32,
pub center_x_frac: f32,
pub focused_field: BuilderField,
pub preview_png: Option<Vec<u8>>,
pub preview_signature: Option<PreviewSignature>,
pub error: Option<String>,
pub from_integration_edit: bool,
pub svg_path_cursor: usize,
pub name_cursor: usize,
pub codepoint_hex_cursor: usize,
}
#[derive(Debug, Clone, Copy)]
struct FieldDefaults {
width_frac: f32,
height_frac: f32,
center_frac: f32,
center_x_frac: f32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreviewSignature {
pub path: String,
pub w: u32, pub h: u32,
pub c: u32, pub cx: u32, }
impl Default for GlyphBuilderState {
fn default() -> Self {
Self {
svg_path: String::new(),
category: BuilderCategory::Aws,
name: String::new(),
codepoint_hex: format!("{:04X}", BuilderCategory::Aws.range_start()),
width_frac: 1.25,
height_frac: 0.80,
center_frac: 0.36,
center_x_frac: 0.5,
focused_field: BuilderField::Path,
preview_png: None,
preview_signature: None,
error: None,
from_integration_edit: false,
svg_path_cursor: 0,
name_cursor: 0,
codepoint_hex_cursor: 0,
}
}
}
impl GlyphBuilderState {
pub fn new() -> Self {
Self::default()
}
pub fn signature(&self) -> PreviewSignature {
PreviewSignature {
path: self.svg_path.clone(),
w: self.width_frac.to_bits(),
h: self.height_frac.to_bits(),
c: self.center_frac.to_bits(),
cx: self.center_x_frac.to_bits(),
}
}
pub fn cycle_value(&mut self, delta: isize) {
match self.focused_field {
BuilderField::Category => {
self.category = self.category.cycled(delta);
self.codepoint_hex = format!("{:04X}", self.category.range_start());
}
BuilderField::WidthFrac => {
self.width_frac = (self.width_frac + 0.05 * delta as f32).clamp(0.5, 2.0);
}
BuilderField::HeightFrac => {
self.height_frac = (self.height_frac + 0.05 * delta as f32).clamp(0.4, 1.2);
}
BuilderField::CenterFrac => {
self.center_frac = (self.center_frac + 0.02 * delta as f32).clamp(0.2, 0.6);
}
BuilderField::CenterXFrac => {
self.center_x_frac = (self.center_x_frac + 0.02 * delta as f32).clamp(0.2, 0.8);
}
_ => {}
}
}
pub fn reset_focused_to_default(&mut self) {
let defaults = self.defaults_for_current_codepoint();
match self.focused_field {
BuilderField::WidthFrac => self.width_frac = defaults.width_frac,
BuilderField::HeightFrac => self.height_frac = defaults.height_frac,
BuilderField::CenterFrac => self.center_frac = defaults.center_frac,
BuilderField::CenterXFrac => self.center_x_frac = defaults.center_x_frac,
_ => {}
}
}
pub fn reset_all_to_default(&mut self) {
let defaults = self.defaults_for_current_codepoint();
self.width_frac = defaults.width_frac;
self.height_frac = defaults.height_frac;
self.center_frac = defaults.center_frac;
self.center_x_frac = defaults.center_x_frac;
}
fn defaults_for_current_codepoint(&self) -> FieldDefaults {
u32::from_str_radix(&self.codepoint_hex, 16)
.ok()
.and_then(builtin_for_codepoint)
.map(|bi| FieldDefaults {
width_frac: bi.width_frac,
height_frac: bi.height_frac,
center_frac: bi.center_frac,
center_x_frac: bi.center_x_frac,
})
.unwrap_or(FieldDefaults {
width_frac: 1.25,
height_frac: 0.80,
center_frac: 0.36,
center_x_frac: 0.50,
})
}
pub fn type_char(&mut self, ch: char) {
let (buf, cursor, cap): (&mut String, &mut usize, usize) = match self.focused_field {
BuilderField::Path => (&mut self.svg_path, &mut self.svg_path_cursor, 4096),
BuilderField::Name => (&mut self.name, &mut self.name_cursor, 128),
BuilderField::Codepoint => (&mut self.codepoint_hex, &mut self.codepoint_hex_cursor, 5),
_ => return,
};
if buf.chars().count() >= cap {
return;
}
let cur = (*cursor).min(buf.len());
buf.insert(cur, ch);
*cursor = cur + ch.len_utf8();
}
pub fn insert_str(&mut self, s: &str) {
let (buf, cursor, cap): (&mut String, &mut usize, usize) = match self.focused_field {
BuilderField::Path => (&mut self.svg_path, &mut self.svg_path_cursor, 4096),
BuilderField::Name => (&mut self.name, &mut self.name_cursor, 128),
BuilderField::Codepoint => (&mut self.codepoint_hex, &mut self.codepoint_hex_cursor, 5),
_ => return,
};
let cleaned: String = s
.chars()
.filter(|c| !c.is_control() && *c != '\r' && *c != '\n')
.collect();
if cleaned.is_empty() {
return;
}
let existing = buf.chars().count();
let allowed = cap.saturating_sub(existing);
if allowed == 0 {
return;
}
let to_insert: String = cleaned.chars().take(allowed).collect();
let cur = (*cursor).min(buf.len());
buf.insert_str(cur, &to_insert);
*cursor = cur + to_insert.len();
}
pub fn backspace(&mut self) {
let (buf, cursor) = match self.focused_field {
BuilderField::Path => (&mut self.svg_path, &mut self.svg_path_cursor),
BuilderField::Name => (&mut self.name, &mut self.name_cursor),
BuilderField::Codepoint => (&mut self.codepoint_hex, &mut self.codepoint_hex_cursor),
_ => return,
};
let cur = (*cursor).min(buf.len());
if cur == 0 {
return;
}
let prev = buf[..cur]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
buf.replace_range(prev..cur, "");
*cursor = prev;
}
pub fn delete_forward(&mut self) {
let (buf, cursor) = match self.focused_field {
BuilderField::Path => (&mut self.svg_path, &mut self.svg_path_cursor),
BuilderField::Name => (&mut self.name, &mut self.name_cursor),
BuilderField::Codepoint => (&mut self.codepoint_hex, &mut self.codepoint_hex_cursor),
_ => return,
};
let cur = (*cursor).min(buf.len());
if cur >= buf.len() {
return;
}
let end = buf[cur..]
.char_indices()
.nth(1)
.map(|(i, _)| cur + i)
.unwrap_or(buf.len());
buf.replace_range(cur..end, "");
}
pub fn move_cursor_left(&mut self) {
let (buf, cursor) = match self.focused_field {
BuilderField::Path => (&self.svg_path, &mut self.svg_path_cursor),
BuilderField::Name => (&self.name, &mut self.name_cursor),
BuilderField::Codepoint => (&self.codepoint_hex, &mut self.codepoint_hex_cursor),
_ => return,
};
let cur = (*cursor).min(buf.len());
if cur == 0 {
return;
}
let prev = buf[..cur]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
*cursor = prev;
}
pub fn move_cursor_right(&mut self) {
let (buf, cursor) = match self.focused_field {
BuilderField::Path => (&self.svg_path, &mut self.svg_path_cursor),
BuilderField::Name => (&self.name, &mut self.name_cursor),
BuilderField::Codepoint => (&self.codepoint_hex, &mut self.codepoint_hex_cursor),
_ => return,
};
let cur = (*cursor).min(buf.len());
if cur >= buf.len() {
return;
}
let next = buf[cur..]
.char_indices()
.nth(1)
.map(|(i, _)| cur + i)
.unwrap_or(buf.len());
*cursor = next;
}
pub fn move_cursor_home(&mut self) {
match self.focused_field {
BuilderField::Path => self.svg_path_cursor = 0,
BuilderField::Name => self.name_cursor = 0,
BuilderField::Codepoint => self.codepoint_hex_cursor = 0,
_ => {}
}
}
pub fn move_cursor_end(&mut self) {
match self.focused_field {
BuilderField::Path => self.svg_path_cursor = self.svg_path.len(),
BuilderField::Name => self.name_cursor = self.name.len(),
BuilderField::Codepoint => self.codepoint_hex_cursor = self.codepoint_hex.len(),
_ => {}
}
}
pub fn delete_word_back(&mut self) {
let (buf, cursor) = match self.focused_field {
BuilderField::Path => (&mut self.svg_path, &mut self.svg_path_cursor),
BuilderField::Name => (&mut self.name, &mut self.name_cursor),
BuilderField::Codepoint => (&mut self.codepoint_hex, &mut self.codepoint_hex_cursor),
_ => return,
};
let cur = (*cursor).min(buf.len());
let head = &buf[..cur];
let trimmed = head.trim_end_matches(char::is_whitespace);
let cut = trimmed
.char_indices()
.rev()
.find(|&(_, c)| c.is_whitespace())
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
buf.replace_range(cut..cur, "");
*cursor = cut;
}
pub fn delete_to_start(&mut self) {
let (buf, cursor) = match self.focused_field {
BuilderField::Path => (&mut self.svg_path, &mut self.svg_path_cursor),
BuilderField::Name => (&mut self.name, &mut self.name_cursor),
BuilderField::Codepoint => (&mut self.codepoint_hex, &mut self.codepoint_hex_cursor),
_ => return,
};
let cur = (*cursor).min(buf.len());
buf.replace_range(..cur, "");
*cursor = 0;
}
pub fn delete_to_end(&mut self) {
let (buf, cursor) = match self.focused_field {
BuilderField::Path => (&mut self.svg_path, &mut self.svg_path_cursor),
BuilderField::Name => (&mut self.name, &mut self.name_cursor),
BuilderField::Codepoint => (&mut self.codepoint_hex, &mut self.codepoint_hex_cursor),
_ => return,
};
let cur = (*cursor).min(buf.len());
buf.truncate(cur);
}
pub fn active_text_cursor(&self) -> Option<usize> {
Some(match self.focused_field {
BuilderField::Path => self.svg_path_cursor.min(self.svg_path.len()),
BuilderField::Name => self.name_cursor.min(self.name.len()),
BuilderField::Codepoint => self.codepoint_hex_cursor.min(self.codepoint_hex.len()),
_ => return None,
})
}
pub fn cycle_field(&mut self, delta: isize) {
use BuilderField::*;
let order = [
Path,
Category,
Name,
Codepoint,
WidthFrac,
HeightFrac,
CenterFrac,
CenterXFrac,
];
let cur = order
.iter()
.position(|f| *f == self.focused_field)
.unwrap_or(0) as isize;
let n = order.len() as isize;
let next = (cur + delta).rem_euclid(n) as usize;
self.focused_field = order[next];
match self.focused_field {
Path => self.svg_path_cursor = self.svg_path_cursor.min(self.svg_path.len()),
Name => self.name_cursor = self.name_cursor.min(self.name.len()),
Codepoint => {
self.codepoint_hex_cursor = self.codepoint_hex_cursor.min(self.codepoint_hex.len())
}
_ => {}
}
}
}
pub fn rasterize(
path: &str,
width_frac: f32,
height_frac: f32,
center_frac: f32,
center_x_frac: f32,
target_w: u32,
target_h: u32,
) -> Result<Vec<u8>, String> {
if path.trim().is_empty() {
return Err("no SVG path".to_string());
}
let p = Path::new(path);
if !p.exists() {
return Err(format!("file not found: {path}"));
}
let bytes = std::fs::read(p).map_err(|e| format!("read {path}: {e}"))?;
let opt = Options::default();
let tree = Tree::from_data(&bytes, &opt).map_err(|e| format!("parse svg: {e}"))?;
const CELL_W: f32 = 600.0;
const EM: f32 = 1000.0;
let content_bbox = tree.root().abs_bounding_box();
let src_x = content_bbox.x();
let src_y = content_bbox.y();
let src_w = content_bbox.width();
let src_h = content_bbox.height();
if src_w <= 0.0 || src_h <= 0.0 {
return Err("empty svg".to_string());
}
let target_w_units = CELL_W * width_frac;
let target_h_units = EM * height_frac;
let scale = (target_w_units / src_w).min(target_h_units / src_h);
const OVERFLOW_MARGIN: f32 = 1.5;
let box_w = CELL_W * OVERFLOW_MARGIN;
let (pixmap_w, pixmap_h) = if (target_h as f32) * box_w >= (target_w as f32) * EM {
let h = target_h.max(2);
let w = ((h as f32) * box_w / EM).round() as u32;
(w.max(2), h)
} else {
let w = target_w.max(2);
let h = ((w as f32) * EM / box_w).round() as u32;
(w, h.max(2))
};
let px_per_unit = pixmap_h as f32 / EM;
let px_glyph_w = src_w * scale * px_per_unit;
let px_glyph_h = src_h * scale * px_per_unit;
let px_center_y = (1.0 - center_frac) * pixmap_h as f32;
let px_nudge_x = (center_x_frac - 0.5) * CELL_W * px_per_unit;
let px_left = (pixmap_w as f32 - px_glyph_w) / 2.0 + px_nudge_x;
let px_top = px_center_y - px_glyph_h / 2.0;
let mut pixmap = Pixmap::new(pixmap_w, pixmap_h).ok_or("alloc pixmap")?;
let s = scale * px_per_unit;
let t = Transform::from_translate(-src_x, -src_y)
.post_scale(s, s)
.post_translate(px_left, px_top);
resvg::render(&tree, t, &mut pixmap.as_mut());
let img = image::RgbaImage::from_raw(pixmap_w, pixmap_h, pixmap.data().to_vec())
.ok_or("wrap rgba")?;
let mut png = Vec::with_capacity((pixmap_w * pixmap_h) as usize);
let dyn_img = image::DynamicImage::ImageRgba8(img);
dyn_img
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("png encode: {e}"))?;
Ok(png)
}
#[derive(Debug, Clone, Copy)]
pub struct BuiltinGlyph {
pub codepoint: u32,
pub name: &'static str,
pub svg_relpath: &'static str,
pub width_frac: f32,
pub height_frac: f32,
pub center_frac: f32,
pub center_x_frac: f32,
}
pub const BUILTIN_GLYPHS: &[BuiltinGlyph] = &[
BuiltinGlyph {
codepoint: 0xF1E00,
name: "ai-claude-spark",
svg_relpath: "assets/glyphs/ai/claude-spark.svg",
width_frac: 1.35,
height_frac: 1.35,
center_frac: 0.30,
center_x_frac: 0.35,
},
BuiltinGlyph {
codepoint: 0xF1E01,
name: "ai-codex",
svg_relpath: "assets/glyphs/ai/codex.svg",
width_frac: 1.20,
height_frac: 0.75,
center_frac: 0.28,
center_x_frac: 0.5,
},
BuiltinGlyph {
codepoint: 0xF1E10,
name: "ai-spinner-a",
svg_relpath: "assets/glyphs/ai/spinner/spinner-a.svg",
width_frac: 0.90,
height_frac: 0.90,
center_frac: 0.36,
center_x_frac: 0.5,
},
BuiltinGlyph {
codepoint: 0xF1E11,
name: "ai-spinner-b",
svg_relpath: "assets/glyphs/ai/spinner/spinner-b.svg",
width_frac: 0.90,
height_frac: 0.90,
center_frac: 0.36,
center_x_frac: 0.5,
},
BuiltinGlyph {
codepoint: 0xF1E12,
name: "ai-spinner-c",
svg_relpath: "assets/glyphs/ai/spinner/spinner-c.svg",
width_frac: 0.90,
height_frac: 0.90,
center_frac: 0.36,
center_x_frac: 0.5,
},
BuiltinGlyph {
codepoint: 0xF1E13,
name: "ai-spinner-d",
svg_relpath: "assets/glyphs/ai/spinner/spinner-d.svg",
width_frac: 0.90,
height_frac: 0.90,
center_frac: 0.36,
center_x_frac: 0.5,
},
BuiltinGlyph {
codepoint: 0xF1E14,
name: "ai-spinner-e",
svg_relpath: "assets/glyphs/ai/spinner/spinner-e.svg",
width_frac: 0.90,
height_frac: 0.90,
center_frac: 0.36,
center_x_frac: 0.5,
},
BuiltinGlyph {
codepoint: 0xF1F00,
name: "music-beatport",
svg_relpath: "assets/glyphs/music/beatport.svg",
width_frac: 1.0,
height_frac: 1.0,
center_frac: 0.36,
center_x_frac: 0.5,
},
];
const EMBEDDED_SVGS: &[(&str, &[u8])] = &[
(
"assets/glyphs/ai/claude-spark.svg",
include_bytes!("../assets/glyphs/ai/claude-spark.svg"),
),
(
"assets/glyphs/ai/codex.svg",
include_bytes!("../assets/glyphs/ai/codex.svg"),
),
(
"assets/glyphs/ai/spinner/spinner-a.svg",
include_bytes!("../assets/glyphs/ai/spinner/spinner-a.svg"),
),
(
"assets/glyphs/ai/spinner/spinner-b.svg",
include_bytes!("../assets/glyphs/ai/spinner/spinner-b.svg"),
),
(
"assets/glyphs/ai/spinner/spinner-c.svg",
include_bytes!("../assets/glyphs/ai/spinner/spinner-c.svg"),
),
(
"assets/glyphs/ai/spinner/spinner-d.svg",
include_bytes!("../assets/glyphs/ai/spinner/spinner-d.svg"),
),
(
"assets/glyphs/ai/spinner/spinner-e.svg",
include_bytes!("../assets/glyphs/ai/spinner/spinner-e.svg"),
),
(
"assets/glyphs/music/beatport.svg",
include_bytes!("../assets/glyphs/music/beatport.svg"),
),
];
pub fn resolve_builtin_svg(relpath: &str) -> Option<std::path::PathBuf> {
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent()
&& let Some(macos_parent) = parent.parent()
{
let cand = macos_parent.join("Resources").join(relpath);
if cand.exists() {
return Some(cand);
}
}
let mut cur = exe;
while cur.pop() {
let cand = cur.join(relpath);
if cand.exists() {
return Some(cand);
}
}
}
if let Some(home) = std::env::var_os("HOME") {
let projects = std::path::PathBuf::from(home).join("Projects");
for candidate_root in &["mnml", "mnml-one-tab-type"] {
let cand = projects.join(candidate_root).join(relpath);
if cand.exists() {
return Some(cand);
}
}
}
if let Some((_, bytes)) = EMBEDDED_SVGS.iter().find(|(p, _)| *p == relpath) {
let dir = std::env::temp_dir().join("mnml-embedded");
let out = dir.join(relpath);
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
let stale = match std::fs::read(&out) {
Ok(existing) => existing.as_slice() != *bytes,
Err(_) => true,
};
if stale && std::fs::write(&out, bytes).is_err() {
return None;
}
return Some(out);
}
None
}
pub fn builtin_for_codepoint(cp: u32) -> Option<&'static BuiltinGlyph> {
BUILTIN_GLYPHS.iter().find(|g| g.codepoint == cp)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GlyphMeta {
pub codepoint: String,
pub name: String,
pub svg: String,
pub width_frac: f32,
pub height_frac: f32,
pub center_frac: f32,
#[serde(default = "default_center_x_frac")]
pub center_x_frac: f32,
}
fn default_center_x_frac() -> f32 {
0.5
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct GlyphMetaFile {
#[serde(default, rename = "glyph")]
pub glyphs: Vec<GlyphMeta>,
}
pub fn meta_path() -> Option<std::path::PathBuf> {
let cfg = crate::config::user_config_path()?;
let dir = cfg.parent()?;
Some(dir.join("glyph_meta.toml"))
}
pub fn load_meta() -> GlyphMetaFile {
let Some(p) = meta_path() else {
return GlyphMetaFile::default();
};
let Ok(txt) = std::fs::read_to_string(&p) else {
return GlyphMetaFile::default();
};
toml::from_str(&txt).unwrap_or_default()
}
pub fn remove_meta_by_cp_hex(cp_hex: &str) -> bool {
let Some(p) = meta_path() else {
return false;
};
if !p.exists() {
return false;
}
let mut file = load_meta();
let before = file.glyphs.len();
file.glyphs.retain(|g| g.codepoint != cp_hex);
if file.glyphs.len() == before {
return false;
}
let Ok(txt) = toml::to_string_pretty(&file) else {
return false;
};
std::fs::write(&p, txt).is_ok()
}
pub fn upsert_meta(entry: GlyphMeta) {
let Some(p) = meta_path() else {
return;
};
let mut file = load_meta();
file.glyphs.retain(|g| g.codepoint != entry.codepoint);
file.glyphs.push(entry);
file.glyphs.sort_by(|a, b| a.codepoint.cmp(&b.codepoint));
let Ok(txt) = toml::to_string_pretty(&file) else {
return;
};
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&p, txt);
}
pub fn category_for_codepoint(cp: u32) -> BuilderCategory {
for cat in BuilderCategory::ALL {
if cp >= cat.range_start() && cp <= cat.range_end() {
return *cat;
}
}
BuilderCategory::Aws
}
pub fn maybe_refresh_preview(state: &mut GlyphBuilderState, target_w: u32, target_h: u32) {
let sig = state.signature();
if state.preview_signature.as_ref() == Some(&sig) {
return;
}
match rasterize(
&state.svg_path,
state.width_frac,
state.height_frac,
state.center_frac,
state.center_x_frac,
target_w,
target_h,
) {
Ok(png) => {
state.preview_png = Some(png);
state.error = None;
}
Err(msg) => {
state.preview_png = None;
state.error = Some(msg);
}
}
state.preview_signature = Some(sig);
}
#[cfg(test)]
mod tests {
use super::*;
fn state_focused_on_path() -> GlyphBuilderState {
let mut s = GlyphBuilderState::new();
s.focused_field = BuilderField::Path;
s
}
#[test]
fn type_and_backspace_at_cursor() {
let mut s = state_focused_on_path();
s.type_char('a');
s.type_char('b');
s.type_char('c');
assert_eq!(s.svg_path, "abc");
assert_eq!(s.svg_path_cursor, 3);
s.move_cursor_left();
s.move_cursor_left();
assert_eq!(s.svg_path_cursor, 1);
s.type_char('X');
assert_eq!(s.svg_path, "aXbc");
assert_eq!(s.svg_path_cursor, 2);
s.backspace();
assert_eq!(s.svg_path, "abc");
assert_eq!(s.svg_path_cursor, 1);
}
#[test]
fn paste_inserts_at_cursor_stripping_control_chars() {
let mut s = state_focused_on_path();
s.type_char('/');
s.type_char('a');
s.type_char('/');
s.insert_str("Users/chris/foo.svg\n");
assert_eq!(s.svg_path, "/a/Users/chris/foo.svg");
}
#[test]
fn move_home_end_delete_forward() {
let mut s = state_focused_on_path();
for c in "hello".chars() {
s.type_char(c);
}
s.move_cursor_home();
assert_eq!(s.svg_path_cursor, 0);
s.delete_forward();
assert_eq!(s.svg_path, "ello");
s.move_cursor_end();
assert_eq!(s.svg_path_cursor, 4);
}
#[test]
fn cycle_field_clamps_cursor() {
let mut s = state_focused_on_path();
for c in "verylongpath".chars() {
s.type_char(c);
}
assert_eq!(s.svg_path_cursor, 12);
s.cycle_field(2); assert_eq!(s.focused_field, BuilderField::Name);
s.type_char('n');
assert_eq!(s.name, "n");
s.cycle_field(-2);
assert_eq!(s.focused_field, BuilderField::Path);
assert_eq!(s.svg_path_cursor, 12);
}
#[test]
fn reset_focused_uses_builtin_defaults_when_present() {
let expected = super::BUILTIN_GLYPHS
.iter()
.find(|g| g.codepoint == 0xF1E00)
.expect("F1E00 entry present in BUILTIN_GLYPHS");
let mut s = GlyphBuilderState::new();
s.codepoint_hex = "F1E00".to_string();
s.focused_field = BuilderField::CenterFrac;
s.center_frac = 0.99; s.reset_focused_to_default();
assert!((s.center_frac - expected.center_frac).abs() < 1e-6);
let fresh = GlyphBuilderState::new();
assert!((s.width_frac - fresh.width_frac).abs() < 1e-6);
}
#[test]
fn reset_all_resets_every_numeric_field() {
let expected = super::BUILTIN_GLYPHS
.iter()
.find(|g| g.codepoint == 0xF1E00)
.expect("F1E00 entry present in BUILTIN_GLYPHS");
let mut s = GlyphBuilderState::new();
s.codepoint_hex = "F1E00".to_string();
s.width_frac = 0.5;
s.height_frac = 0.5;
s.center_frac = 0.5;
s.center_x_frac = 0.2;
s.reset_all_to_default();
assert!((s.width_frac - expected.width_frac).abs() < 1e-6);
assert!((s.height_frac - expected.height_frac).abs() < 1e-6);
assert!((s.center_frac - expected.center_frac).abs() < 1e-6);
assert!((s.center_x_frac - expected.center_x_frac).abs() < 1e-6);
}
#[test]
fn reset_falls_back_to_hard_defaults_for_unknown_codepoint() {
let mut s = GlyphBuilderState::new();
s.codepoint_hex = "E123".to_string();
s.center_frac = 0.99;
s.center_x_frac = 0.1;
s.reset_all_to_default();
assert!((s.center_frac - 0.36).abs() < 1e-6);
assert!((s.center_x_frac - 0.50).abs() < 1e-6);
}
}