9.5 KiB · text History af1c436
//! 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};

/// 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,
}

#[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 {
    pub scale: f64,
    pub filled: bool,
}

/// 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,
            },
            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(),
            },
        }
    }

    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, .. } => {
                let n = u32::from_str_radix(codepoint.trim_start_matches("U+").trim(), 16)
                    .map_err(|_| format!("bad codepoint {codepoint:?}"))?;
                let ch = char::from_u32(n)
                    .ok_or_else(|| format!("codepoint {codepoint:?} is not a character"))?;
                Foreground::Icon(ch)
            }
            DocForeground::Text { value } => Foreground::Text(value.clone()),
        };

        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,
                show_guides: doc.show_guides,
                filled: doc.mark.filled,
            },
            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)
    }
}