//! Saving and loading icon configurations as TOML.
//!
//! The on-disk shape is deliberately readable and hand-editable: colours carry
//! both their palette role and their literal RGB, and icons carry the glyph
//! name alongside its codepoint. A document therefore still makes sense to a
//! person reading it, and still resolves if the palette it referred to has
//! moved on.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::palette::{self, NamedColor};
use crate::spec::{Background, Foreground, IconSpec, Layer};
/// Bumped only for a breaking change to the file shape. Readers refuse a
/// version from the future rather than silently misreading it.
pub const VERSION: u32 = 1;
pub const EXTENSION: &str = "toml";
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct Document {
pub version: u32,
/// Palette name, e.g. "Casabeza". Advisory: colours below carry their own
/// RGB, so a document opens even if the palette is gone.
pub palette: String,
#[serde(default)]
pub sub_banner: String,
#[serde(default)]
pub show_guides: bool,
pub foreground: DocForeground,
pub colors: DocColors,
pub background: DocBackground,
pub mark: DocMark,
#[serde(default)]
pub export: DocExport,
#[serde(default)]
pub grid: DocGrid,
/// Glyphs stacked on the mark. Last, because TOML arrays of tables have to
/// follow every plain table; omitted entirely when there are none, so
/// single-mark documents read exactly as they did before layers existed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layers: Vec<DocLayer>,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DocForeground {
Icon {
/// Hex codepoint, e.g. "e899". Authoritative.
codepoint: String,
/// Glyph name, written for the benefit of anyone reading the file.
/// Ignored on load — the codepoint wins if they disagree.
#[serde(default)]
name: String,
},
Text {
value: String,
},
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct DocColor {
pub role: String,
pub rgb: [u8; 3],
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct DocColors {
pub foreground: DocColor,
pub background_a: DocColor,
pub background_b: DocColor,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct DocBackground {
/// One of [`Background::id`].
pub style: String,
pub angle: f64,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct DocMark {
/// Overall scale of the mark and its layers.
pub scale: f64,
pub filled: bool,
/// The main mark's size relative to `scale`. Left out at 1.0, which is
/// what documents written before layers existed mean.
#[serde(default = "one", skip_serializing_if = "is_one")]
pub size: f64,
/// Degrees clockwise. Left out when zero, as are offsets, so a plain
/// centred mark reads as it always has.
#[serde(default, skip_serializing_if = "is_zero")]
pub rotation: f64,
/// Shift from centre in units of `scale`, `[x, y]`, +y down.
#[serde(default, skip_serializing_if = "is_origin")]
pub offset: [f64; 2],
}
fn one() -> f64 {
1.0
}
fn is_one(v: &f64) -> bool {
*v == 1.0
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
fn is_origin(v: &[f64; 2]) -> bool {
*v == [0.0, 0.0]
}
fn tidy2(v: [f32; 2]) -> [f64; 2] {
[tidy(v[0]), tidy(v[1])]
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct DocLayer {
/// Hex codepoint, as for an icon foreground. Authoritative.
pub codepoint: String,
/// Glyph name, for readers. Ignored on load.
#[serde(default)]
pub name: String,
/// Relative to `mark.scale`, not to the canvas.
pub size: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub rotation: f64,
#[serde(default, skip_serializing_if = "is_origin")]
pub offset: [f64; 2],
#[serde(default)]
pub filled: bool,
/// Absent means the layer follows the foreground colour. Last, because a
/// TOML sub-table has to follow the layer's plain values.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<DocColor>,
}
fn parse_codepoint(codepoint: &str) -> Result<char, String> {
let n = u32::from_str_radix(codepoint.trim_start_matches("U+").trim(), 16)
.map_err(|_| format!("bad codepoint {codepoint:?}"))?;
char::from_u32(n).ok_or_else(|| format!("codepoint {codepoint:?} is not a character"))
}
/// Widening an f32 to f64 exposes its binary tail — 0.54f32 becomes
/// 0.5400000214576721 — which is noise in a file meant to be read and edited.
fn tidy(v: f32) -> f64 {
((v as f64) * 1000.0).round() / 1000.0
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)]
pub struct DocExport {
#[serde(default)]
pub name: String,
#[serde(default)]
pub directory: String,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)]
pub struct DocGrid {
#[serde(default)]
pub masked: bool,
/// Palette roles in play for the combination grid.
#[serde(default)]
pub colors: Vec<String>,
/// Background ids in play.
#[serde(default)]
pub backgrounds: Vec<String>,
}
fn color_to_doc(c: NamedColor) -> DocColor {
DocColor {
role: c.role.to_string(),
rgb: c.rgb,
}
}
/// Resolves a saved colour back to a palette entry.
///
/// The role is matched against the named palette first, so a document follows
/// a theme that has been retuned since it was written. The stored RGB is the
/// fallback when the role is gone, which keeps old documents openable.
fn color_from_doc(d: &DocColor, theme: Option<&palette::Theme>) -> NamedColor {
if let Some(t) = theme {
if let Some(found) = t.get(&d.role) {
return found;
}
}
// Leaked so the rest of the program can keep using &'static str for roles
// that came from a file. Documents are opened a handful of times per
// session, so this is bounded in practice.
NamedColor {
role: Box::leak(d.role.clone().into_boxed_str()),
rgb: d.rgb,
}
}
/// Everything the UI needs to restore a session, not just what the renderer
/// needs to draw.
#[derive(Clone, PartialEq, Debug)]
pub struct Session {
pub spec: IconSpec,
pub palette: String,
pub export_name: String,
pub export_dir: String,
pub grid_masked: bool,
pub grid_colors: Vec<String>,
pub grid_backgrounds: Vec<Background>,
}
impl Session {
pub fn to_document(&self, icon_name_for: impl Fn(char) -> String) -> Document {
Document {
version: VERSION,
palette: self.palette.clone(),
sub_banner: self.spec.sub_banner.clone(),
show_guides: self.spec.show_guides,
foreground: match &self.spec.foreground {
Foreground::Icon(c) => DocForeground::Icon {
codepoint: format!("{:x}", *c as u32),
name: icon_name_for(*c),
},
Foreground::Text(t) => DocForeground::Text { value: t.clone() },
},
colors: DocColors {
foreground: color_to_doc(self.spec.fg_color),
background_a: color_to_doc(self.spec.bg_a),
background_b: color_to_doc(self.spec.bg_b),
},
background: DocBackground {
style: self.spec.background.id().to_string(),
angle: tidy(self.spec.angle),
},
mark: DocMark {
scale: tidy(self.spec.scale),
filled: self.spec.filled,
size: tidy(self.spec.mark_size),
rotation: tidy(self.spec.mark_rotation),
offset: tidy2(self.spec.mark_offset),
},
export: DocExport {
name: self.export_name.clone(),
directory: self.export_dir.clone(),
},
grid: DocGrid {
masked: self.grid_masked,
colors: self.grid_colors.clone(),
backgrounds: self
.grid_backgrounds
.iter()
.map(|b| b.id().to_string())
.collect(),
},
layers: self
.spec
.layers
.iter()
.map(|l| DocLayer {
codepoint: format!("{:x}", l.glyph as u32),
name: icon_name_for(l.glyph),
size: tidy(l.size),
rotation: tidy(l.rotation),
offset: tidy2(l.offset),
filled: l.filled,
color: l.color.map(color_to_doc),
})
.collect(),
}
}
pub fn from_document(doc: &Document) -> Result<Session, String> {
if doc.version > VERSION {
return Err(format!(
"document is version {} but this build understands up to {VERSION}",
doc.version
));
}
let theme = palette::theme_by_name(&doc.palette);
let foreground = match &doc.foreground {
DocForeground::Icon { codepoint, .. } => Foreground::Icon(parse_codepoint(codepoint)?),
DocForeground::Text { value } => Foreground::Text(value.clone()),
};
let layers = doc
.layers
.iter()
.map(|l| {
Ok(Layer {
glyph: parse_codepoint(&l.codepoint)?,
color: l.color.as_ref().map(|c| color_from_doc(c, theme)),
size: l.size as f32,
rotation: l.rotation as f32,
offset: [l.offset[0] as f32, l.offset[1] as f32],
filled: l.filled,
})
})
.collect::<Result<Vec<_>, String>>()?;
let background = Background::from_id(&doc.background.style)
.ok_or_else(|| format!("unknown background style {:?}", doc.background.style))?;
Ok(Session {
spec: IconSpec {
foreground,
sub_banner: doc.sub_banner.clone(),
fg_color: color_from_doc(&doc.colors.foreground, theme),
bg_a: color_from_doc(&doc.colors.background_a, theme),
bg_b: color_from_doc(&doc.colors.background_b, theme),
background,
angle: doc.background.angle as f32,
scale: doc.mark.scale as f32,
mark_size: doc.mark.size as f32,
mark_rotation: doc.mark.rotation as f32,
mark_offset: [doc.mark.offset[0] as f32, doc.mark.offset[1] as f32],
show_guides: doc.show_guides,
filled: doc.mark.filled,
layers,
},
palette: doc.palette.clone(),
export_name: if doc.export.name.is_empty() {
"ic_icon".to_string()
} else {
doc.export.name.clone()
},
export_dir: doc.export.directory.clone(),
grid_masked: doc.grid.masked,
grid_colors: doc.grid.colors.clone(),
// Silently drop ids this build does not know rather than refusing
// to open the document over a grid preference.
grid_backgrounds: doc
.grid
.backgrounds
.iter()
.filter_map(|id| Background::from_id(id))
.collect(),
})
}
}
pub fn to_toml(doc: &Document) -> Result<String, String> {
// to_string_pretty explodes short arrays across lines; an rgb triple
// reads better inline.
let body = toml::to_string(doc).map_err(|e| e.to_string())?;
Ok(format!(
"# Icon Studio document. Hand-editing is fine.\n\
#\n\
# Colours carry both a palette role and literal RGB: the role wins if the\n\
# named palette still defines it, so retuning a theme updates documents\n\
# that reference it; the RGB is the fallback when it does not.\n\n{body}"
))
}
pub fn from_toml(text: &str) -> Result<Document, String> {
toml::from_str(text).map_err(|e| e.to_string())
}
pub fn save(path: &Path, doc: &Document) -> Result<(), String> {
let text = to_toml(doc)?;
std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn load(path: &Path) -> Result<Document, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
from_toml(&text).map_err(|e| format!("{}: {e}", path.display()))
}
/// Appends the document extension unless the user already typed one.
pub fn with_extension(p: PathBuf) -> PathBuf {
if p.extension().is_some() {
p
} else {
p.with_extension(EXTENSION)
}
}
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
12.8 KiB · text
History
6811748