Merge pull request #1641 from ChristopherLinnett/feat/1280-color-calculations

M3: colour variables can be calculated from others, faded or mixed per mode (#1280)
This commit is contained in:
BFlat
2026-10-30 16:16:23 +07:00
committed by GitHub
30 changed files with 1505 additions and 214 deletions
+14 -2
View File
@@ -89,8 +89,20 @@ impl Style {
impl Variables {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
for v in self.variables.iter_mut().flat_map(|v| v.values.values_mut()) {
if let VariableValue::Color(c) = v {
f(c);
match v {
VariableValue::Color(c) => f(c),
// A mix's literals are stored colours too (a calculation's alpha is just a
// number): a profile conversion that left them behind would blend converted
// operands with unconverted ones, and the bound layers would silently shift
// relative to the rest of the document.
VariableValue::ColorMix { from, to, .. } => {
for op in [from, to] {
if let crate::ColorOperand::Literal { literal } = op {
f(literal);
}
}
}
_ => {}
}
}
}
+1 -1
View File
@@ -234,7 +234,7 @@ fn as_text(v: &VariableValue) -> Option<String> {
match v {
VariableValue::String(s) => Some(s.clone()),
VariableValue::Number(_) | VariableValue::Boolean(_) => Some(v.display()),
VariableValue::Color(_) | VariableValue::Alias(_) => None,
VariableValue::Color(_) | VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None,
}
}
+254 -12
View File
@@ -82,6 +82,51 @@ pub enum VariableValue {
String(String),
Boolean(bool),
Alias(VariableId),
/// The referenced colour with its alpha channel replaced (a "50% variant"). Replace rather
/// than scale: applying it twice is the same value (stable under editing the opacity or the
/// chain it reads), and "at 50%" reads as the design ask.
ColorAlpha {
variable: VariableId,
opacity: f32,
},
/// A linear blend of two colour operands at `t` (0 = `from`, 1 = `to`). The space is the stored
/// one — non-premultiplied components in the document's colour profile, alpha blended like the
/// channels — deliberately not perceptual: the values a hex panel can show blend exactly, and
/// 8-bit rounding stays where it already was (display and export) rather than being done twice.
/// The trade-off: converting the profile keeps the ends exact but moves the colours in between.
ColorMix {
from: ColorOperand,
to: ColorOperand,
t: f32,
},
}
/// One operand of a colour calculation: a variable (resolved when read, so it follows its own modes,
/// aliases and calculations) or a fixed colour. Tagged by key, not by type: a bare string is a
/// colour name just as easily as a variable name.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ColorOperand {
Variable { variable: VariableId },
Literal { literal: Color },
}
impl ColorOperand {
/// The variable it reads, if it reads one.
pub fn variable(&self) -> Option<&VariableId> {
match self {
ColorOperand::Variable { variable } => Some(variable),
ColorOperand::Literal { .. } => None,
}
}
/// The variable id it reads, for a remap (library import and swap re-point ids).
pub fn variable_mut(&mut self) -> Option<&mut VariableId> {
match self {
ColorOperand::Variable { variable } => Some(variable),
ColorOperand::Literal { .. } => None,
}
}
}
impl VariableValue {
@@ -95,22 +140,63 @@ impl VariableValue {
}
}
/// The type this value has on its own, which a typed literal needs. None for references
/// (aliases and colour calculations): what they read may be missing or loop, so only
/// resolving them gives a type, and an expression can't take one as a literal.
pub fn type_of(&self) -> Option<VariableType> {
match self {
VariableValue::Color(_) => Some(VariableType::Color),
VariableValue::Number(_) => Some(VariableType::Number),
VariableValue::String(_) => Some(VariableType::String),
VariableValue::Boolean(_) => Some(VariableType::Boolean),
VariableValue::Alias(_) => None,
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None,
}
}
/// The value on one line, as the panel shows it: `→ id` for an alias, `α 50% · id` and
/// `from + to @ 40%` for colour calculations, variables by id ([`Self::display_with`] names
/// them).
pub fn display(&self) -> String {
self.display_with(&|id| id.to_string())
}
/// As [`Self::display`], with the variables it reads shown by `name` (the panel shows their
/// names where ids would mean nothing to a reader).
pub fn display_with(&self, name: &dyn Fn(&str) -> String) -> String {
let percent = |x: f32| (x * 100.0).round();
let operand = |op: &ColorOperand| match op {
ColorOperand::Variable { variable } => name(variable),
// As the panel shows a colour (no `#`), with alpha digits when it isn't opaque.
ColorOperand::Literal { literal } => literal.to_css_hex().trim_start_matches('#').to_string(),
};
match self {
VariableValue::Color(c) => c.to_hex(),
VariableValue::Number(n) => crate::text::trim_num(*n),
VariableValue::String(s) => s.clone(),
VariableValue::Boolean(b) => b.to_string(),
VariableValue::Alias(a) => format!("→ {a}"),
VariableValue::Alias(a) => format!("→ {}", name(a)),
VariableValue::ColorAlpha { variable, opacity } => format!("α {}% · {}", percent(*opacity), name(variable)),
VariableValue::ColorMix { from, to, t } => format!("{} + {} @ {}%", operand(from), operand(to), percent(*t)),
}
}
/// The variable ids this value reads (its alias target, or its calculation's variable operands;
/// literal operands read nothing). Used for reference walks: cycles, deletion, library remaps.
pub fn references(&self) -> Vec<&VariableId> {
match self {
VariableValue::Alias(a) => vec![a],
VariableValue::ColorAlpha { variable, .. } => vec![variable],
VariableValue::ColorMix { from, to, .. } => from.variable().into_iter().chain(to.variable()).collect(),
_ => Vec::new(),
}
}
/// [`Self::references`], to re-point: library import and swap move them to other ids.
pub fn references_mut(&mut self) -> Vec<&mut VariableId> {
match self {
VariableValue::Alias(a) => vec![a],
VariableValue::ColorAlpha { variable, .. } => vec![variable],
VariableValue::ColorMix { from, to, .. } => from.variable_mut().into_iter().chain(to.variable_mut()).collect(),
_ => Vec::new(),
}
}
}
@@ -185,6 +271,12 @@ pub struct Variable {
pub const CODE_PLATFORMS: [&str; 3] = ["WEB", "iOS", "ANDROID"];
impl Variable {
/// Its value in `mode`, or its first value when it has none there: an export writes every
/// mode of the collection, given a value in each or not.
pub fn value_or_first(&self, mode: &str) -> Option<&VariableValue> {
self.values.get(mode).or_else(|| self.values.values().next())
}
/// Whether pickers offer this variable for properties of `scope`.
pub fn in_scope(&self, scope: &str) -> bool {
self.scopes.as_ref().is_none_or(|list| list.iter().any(|s| s == scope || s == ALL_SCOPES || (s == "ALL_FILLS" && scope.ends_with("_FILL"))))
@@ -283,19 +375,169 @@ impl Variables {
self.collections.iter().find(|c| c.id == id)
}
/// The distinct variables one resolve may consult. Calculations read trees, not chains, so the
/// cap counts variables rather than hops; with the memo each one is resolved at most once per
/// call, so the work is bounded by this however the graph is shaped (a diamond re-sharing a
/// subtree does not walk it twice).
pub const RESOLVE_LIMIT: usize = 32;
/// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases
/// (bounded, cycles return None).
/// and evaluating colour calculations (bounded, cycles and over-deep graphs return None).
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
let mut cur = id.to_string();
for _ in 0..32 {
let var = self.get_by_id(&cur)?;
let coll = self.collection_by_id(&var.collection)?;
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
match var.values.get(mode).or_else(|| var.values.values().next())? {
VariableValue::Alias(next) => cur = next.clone(),
v => return Some(v.clone()),
self.resolve_memo(id, modes, &mut BTreeMap::new())
}
/// `val`, a value of `var` shown in `mode`, as a format without colour calculations writes it:
/// a calculation becomes the colour it evaluates to there (other collections in their default
/// modes), anything else stays as given. None: the calculation doesn't resolve.
pub fn flattened(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option<VariableValue> {
self.flattened_as(&var.id, &var.collection, mode, val)
}
/// [`Self::flattened`] for a value of variable `id` in `collection`, which need not exist yet.
pub fn flattened_as(&self, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Option<VariableValue> {
match val {
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => {
// `id` is in flight from the start, so a calculation that reads it back resolves to
// nothing, as it would through `resolve`.
let mut done = BTreeMap::from([(id.to_string(), None)]);
self.eval(val, &BTreeMap::from([(collection.to_string(), mode.to_string())]), &mut done)
}
other => Some(other.clone()),
}
}
/// Values as an export without colour calculations writes them, references included: see
/// [`Flattener::value`].
pub fn flattener(&self) -> Flattener<'_> {
let mut out = BTreeSet::new();
for var in &self.variables {
let Some(coll) = self.collection_by_id(&var.collection) else { continue };
if coll.modes.iter().any(|m| var.value_or_first(&m.id).is_some_and(|v| self.flattened(var, &m.id, v).is_none())) {
out.insert(var.id.as_str());
}
}
None
// Then every alias that leads to one, back along alias chains.
let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for var in &self.variables {
for value in var.values.values() {
if let VariableValue::Alias(target) = value {
readers.entry(target.as_str()).or_default().push(var.id.as_str());
}
}
}
let mut stack: Vec<&str> = out.iter().copied().collect();
while let Some(id) = stack.pop() {
for reader in readers.get(id).into_iter().flatten() {
if out.insert(reader) {
stack.push(reader);
}
}
}
Flattener { vars: self, unreferenceable: out }
}
/// The stored value of `id` in its collection's current mode (the mode chosen when it has a
/// value for it, else the default mode, else any value).
fn value_in(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<&VariableValue> {
let var = self.get_by_id(id)?;
let coll = self.collection_by_id(&var.collection)?;
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
var.values.get(mode).or_else(|| var.values.values().next())
}
/// [`Self::resolve`] with `done` shared by every variable one call reads: each is resolved once
/// and its outcome kept, one met again while still in flight is a cycle (None), and the call
/// reads at most [`Self::RESOLVE_LIMIT`] variables.
fn resolve_memo(
&self,
id: &str,
modes: &BTreeMap<CollectionId, ModeId>,
done: &mut BTreeMap<String, Option<VariableValue>>,
) -> Option<VariableValue> {
if let Some(hit) = done.get(id) {
return hit.clone();
}
if done.len() >= Self::RESOLVE_LIMIT {
return None;
}
// Mark as being resolved before recursing: meeting it again mid-flight is a cycle, and the
// stored None unwinds it. Overwritten with the outcome below (a cached failure stays cached:
// a variable resolves the same however deep it is reached from — the limit only bails, it
// does not cache).
done.insert(id.to_string(), None);
let out = self.value_in(id, modes).and_then(|val| self.eval(val, modes, done));
done.insert(id.to_string(), out.clone());
out
}
/// A stored value reduced to a plain one (aliases walked, calculations evaluated).
fn eval(
&self,
val: &VariableValue,
modes: &BTreeMap<CollectionId, ModeId>,
done: &mut BTreeMap<String, Option<VariableValue>>,
) -> Option<VariableValue> {
match val {
VariableValue::Alias(next) => self.resolve_memo(next, modes, done),
VariableValue::ColorAlpha { variable, opacity } => match self.resolve_memo(variable, modes, done)? {
VariableValue::Color(c) => Some(VariableValue::Color(c.with_alpha(*opacity))),
_ => None,
},
VariableValue::ColorMix { from, to, t } => {
let a = self.operand_color(from, modes, done)?;
let b = self.operand_color(to, modes, done)?;
Some(VariableValue::Color(mix_colors(&a, &b, *t)))
}
other => Some(other.clone()),
}
}
/// A colour operand: a literal as stored, or a variable that must itself resolve to a colour.
fn operand_color(
&self,
op: &ColorOperand,
modes: &BTreeMap<CollectionId, ModeId>,
done: &mut BTreeMap<String, Option<VariableValue>>,
) -> Option<Color> {
match op {
ColorOperand::Literal { literal } => Some(literal.sanitized()),
ColorOperand::Variable { variable } => match self.resolve_memo(variable, modes, done)? {
VariableValue::Color(c) => Some(c),
_ => None,
},
}
}
}
/// Linear per-channel interpolation (`t` clamped and NaN-guarded: a command validates, a crafted
/// file must still resolve to a sane colour).
/// Values as an export that leaves out what resolves to nothing writes them ([`Variables::flattener`]).
pub struct Flattener<'v> {
vars: &'v Variables,
/// Colour calculations that resolve to nothing in some mode of their collection, and every
/// alias that leads to one.
unreferenceable: BTreeSet<&'v str>,
}
impl Flattener<'_> {
/// `val`, a value of `var` in `mode`, as [`Variables::flattened`] writes it, and an alias that
/// leads to a calculation some mode leaves out also as the value it shows here: a reference to
/// it would read nothing whenever that mode applies. None: it shows nothing in this mode.
pub fn value(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option<VariableValue> {
match self.vars.flattened(var, mode, val)? {
VariableValue::Alias(target) if self.unreferenceable.contains(target.as_str()) => {
self.vars.resolve(&var.id, &BTreeMap::from([(var.collection.clone(), mode.to_string())]))
}
other => Some(other),
}
}
}
fn mix_colors(a: &Color, b: &Color, t: f32) -> Color {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
// Weighted at both ends so t = 0 and t = 1 give each operand exactly: `x + (y - x) * t` can
// miss `y` by a rounding step (from 1.0 to 1e-8 it lands on 0).
let lerp = |x: f32, y: f32| x * (1.0 - t) + y * t;
Color::new(lerp(a.r, b.r), lerp(a.g, b.g), lerp(a.b, b.b), lerp(a.a, b.a))
}
+170 -66
View File
@@ -249,64 +249,14 @@ fn vector_network_paths() {
#[test]
fn variables_resolve_modes_and_aliases() {
let mut v = Variables::default();
v.collections.push(Collection {
id: "c1".into(),
name: "Theme".into(),
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
extends: None,
});
let mut vals = std::collections::BTreeMap::new();
vals.insert("light".to_string(), VariableValue::Color(Color::WHITE));
vals.insert("dark".to_string(), VariableValue::Color(Color::BLACK));
v.variables.push(Variable {
id: "bg".into(),
name: "bg".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: vals,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
let mut alias = std::collections::BTreeMap::new();
alias.insert("light".to_string(), VariableValue::Alias("bg".into()));
v.variables.push(Variable {
id: "surface".into(),
name: "surface".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: alias,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
let mut v = vars_with_colours(&[("bg", VariableValue::Color(Color::WHITE), VariableValue::Color(Color::BLACK))]);
add(&mut v, "surface", VariableValue::Alias("bg".into()));
let mut modes = std::collections::BTreeMap::new();
assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE)));
modes.insert("c1".to_string(), "dark".to_string());
assert_eq!(v.resolve("bg", &modes), Some(VariableValue::Color(Color::BLACK)));
// Cycle.
let mut cyc = std::collections::BTreeMap::new();
cyc.insert("light".to_string(), VariableValue::Alias("loop".into()));
v.variables.push(Variable {
id: "loop".into(),
name: "loop".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: cyc,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
add(&mut v, "loop", VariableValue::Alias("loop".into()));
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
@@ -319,19 +269,7 @@ fn variables_resolve_stored_ids_never_by_name() {
modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }],
extends: None,
});
let var = |id: &str, name: &str, value: VariableValue| Variable {
id: id.into(),
name: name.into(),
collection: "c1".into(),
kind: VariableType::Color,
values: std::collections::BTreeMap::from([("m".to_string(), value)]),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
};
let var = |id: &str, name: &str, value: VariableValue| colour_var(id, name, &[("m", value)]);
let red = Color::from_hex("#FF0000").unwrap();
let blue = Color::from_hex("#0D99FF").unwrap();
// Named like the next variable's id, and like an id that no longer exists.
@@ -722,3 +660,169 @@ fn stored_variable_ids_resolve_only_their_exact_collection() {
vars.collections.retain(|c| c.id != "c");
assert_eq!(vars.resolve("n", &Default::default()), None);
}
// ——— colour calculations ———
/// A colour variable of collection `c1` with `values` by mode id.
fn colour_var(id: &str, name: &str, values: &[(&str, VariableValue)]) -> Variable {
Variable {
id: id.into(),
name: name.into(),
collection: "c1".into(),
kind: VariableType::Color,
values: values.iter().map(|(m, v)| (m.to_string(), v.clone())).collect(),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
}
}
/// `c1` with Light/Dark (Light is the default mode) and colour variables `("id", light, dark)`.
fn vars_with_colours(cols: &[(&str, VariableValue, VariableValue)]) -> Variables {
let mut v = Variables::default();
v.collections.push(Collection {
id: "c1".into(),
name: "C".into(),
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
extends: None,
});
for (id, light, dark) in cols {
v.variables.push(colour_var(id, id, &[("light", light.clone()), ("dark", dark.clone())]));
}
v
}
/// Adds `id` with a value in the Light mode only.
fn add(v: &mut Variables, id: &str, light: VariableValue) {
v.variables.push(colour_var(id, id, &[("light", light)]));
}
fn fade(source: &str, opacity: f32) -> VariableValue {
VariableValue::ColorAlpha { variable: source.into(), opacity }
}
fn mix(from: ColorOperand, to: ColorOperand, t: f32) -> VariableValue {
VariableValue::ColorMix { from, to, t }
}
fn lit(hex: &str) -> ColorOperand {
ColorOperand::Literal { literal: Color::from_hex_or_name(hex).unwrap() }
}
fn src(id: &str) -> ColorOperand {
ColorOperand::Variable { variable: id.into() }
}
fn red() -> VariableValue {
VariableValue::Color(Color::from_rgb8_const(255, 0, 0))
}
fn resolved(v: &Variables, id: &str, mode: Option<&str>) -> Color {
let modes: std::collections::BTreeMap<String, String> =
mode.map(|m| std::collections::BTreeMap::from([("c1".to_string(), m.to_string())])).unwrap_or_default();
match v.resolve(id, &modes) {
Some(VariableValue::Color(c)) => c,
other => panic!("{id} did not resolve to a colour: {other:?}"),
}
}
#[test]
fn an_alpha_calculation_replaces_alpha_and_follows_its_source() {
let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]);
add(&mut v, "fade", fade("base", 0.5));
assert_eq!(resolved(&v, "fade", None).to_rgba8(), [255, 0, 0, 128], "default mode: red at half alpha (replace)");
assert_eq!(resolved(&v, "fade", Some("dark")).to_rgba8(), [0, 0, 255, 128], "the source's value in the chosen mode is what fades");
}
#[test]
fn an_alpha_calculation_replaces_even_a_faded_sources_alpha() {
// Replace (not scale): a fade of a source that already has alpha lands on exactly the requested
// opacity, so re-editing the source's alpha cannot compound with the fade's.
let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25));
let mut v = vars_with_colours(&[("base", quarter.clone(), quarter)]);
add(&mut v, "fade", fade("base", 0.5));
assert_eq!(resolved(&v, "fade", None).a, 0.5, "not 0.25 * 0.5");
}
#[test]
fn an_alpha_calculation_reads_through_an_alias() {
let mut v = vars_with_colours(&[("base", red(), red())]);
add(&mut v, "mirror", VariableValue::Alias("base".into()));
add(&mut v, "faded", fade("mirror", 0.5));
assert_eq!(resolved(&v, "faded", None).to_rgba8(), [255, 0, 0, 128]);
}
#[test]
fn a_mix_blends_stored_components_with_exact_edges() {
let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]);
add(&mut v, "t0", mix(src("base"), lit("#FFFFFF"), 0.0));
add(&mut v, "t1", mix(src("base"), lit("#FFFFFF"), 1.0));
add(&mut v, "grey", mix(lit("#000000"), lit("#FFFFFF"), 0.4));
add(&mut v, "afade", mix(lit("#FF0000"), lit("#FF000000"), 0.25));
assert_eq!(resolved(&v, "t0", None).to_rgba8(), [255, 0, 0, 255], "t=0 is exactly the first operand");
assert_eq!(resolved(&v, "t1", None).to_rgba8(), [255, 255, 255, 255], "t=1 is exactly the second");
assert_eq!(resolved(&v, "grey", None).to_rgba8(), [102, 102, 102, 255], "0.4 of white rounds to 102 (0x66)");
assert_eq!(resolved(&v, "afade", None).to_rgba8(), [255, 0, 0, 191], "alpha blends like the channels (0.75 -> 0xBF)");
assert_eq!(resolved(&v, "t0", Some("dark")).to_rgba8(), [0, 0, 255, 255], "the variable operand follows the mode");
// Exact in the stored components too, not only after 8-bit rounding.
let faint = ColorOperand::Literal { literal: Color::new(1e-8, 0.0, 0.0, 1.0) };
add(&mut v, "to_faint", mix(lit("#FF0000"), faint.clone(), 1.0));
add(&mut v, "from_faint", mix(faint, lit("#FF0000"), 0.0));
assert_eq!(resolved(&v, "to_faint", None).r, 1e-8, "t=1 is the second operand to the last bit");
assert_eq!(resolved(&v, "from_faint", None).r, 1e-8, "t=0 is the first operand to the last bit");
}
#[test]
fn calculations_compose_and_a_crafted_cycle_terminates() {
let mut v = vars_with_colours(&[("base", red(), red())]);
add(&mut v, "fade", fade("base", 0.5));
add(&mut v, "toast", mix(src("fade"), lit("#FFFFFF"), 0.0));
assert_eq!(resolved(&v, "toast", None).to_rgba8(), [255, 0, 0, 128], "a calculation may read a calculation");
// A hand-crafted cycle (commands refuse these) must unwind, never spin.
add(&mut v, "loop", mix(src("loop2"), lit("#FFFFFF"), 0.5));
add(&mut v, "loop2", fade("loop", 0.5));
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
#[test]
fn calculation_resolution_is_memoised_and_bounded() {
// A doubling tree: each level reads the one below twice. Memoised per resolve, that is
// one pass over the levels; unmemoised it is 2^23 walks (the time bound catches a regression).
let mut v = vars_with_colours(&[("leaf", red(), red())]);
let mut prev = "leaf".to_string();
for i in 0..23 {
let id = format!("f{i}");
add(&mut v, &id, mix(src(&prev), src(&prev), 0.5));
prev = id;
}
let started = std::time::Instant::now();
assert_eq!(resolved(&v, &prev, None).to_rgba8(), [255, 0, 0, 255], "the tree reads the leaf");
assert!(started.elapsed().as_millis() < 200, "memoised: {:?}", started.elapsed());
// A diamond: two fades of one base under one mix (shared subtrees each resolved once).
let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25));
let mut d = vars_with_colours(&[("base", quarter.clone(), quarter)]);
add(&mut d, "fa", fade("base", 0.25));
add(&mut d, "fb", fade("base", 0.75));
add(&mut d, "dia", mix(src("fa"), src("fb"), 0.5));
assert_eq!(resolved(&d, "dia", None).a, 0.5, "0.25 + (0.75 - 0.25) * 0.5");
// One resolve reads at most 32 variables, however they are chained.
let mut long = vars_with_colours(&[("v0", red(), red())]);
for i in 1..=40 {
add(&mut long, &format!("v{i}"), fade(&format!("v{}", i - 1), 0.99));
}
assert_eq!(resolved(&long, "v31", None).to_rgba8(), [255, 0, 0, 252], "32 variables still resolve");
assert_eq!(long.resolve("v32", &std::collections::BTreeMap::new()), None, "a 33rd reads nothing");
}
#[test]
fn the_colour_walker_visits_calculation_literals() {
// Document::map_colors (profile conversion) must reach the colours stored inside a mix:
// leaving them behind would blend converted operands with unconverted ones.
let mut v = vars_with_colours(&[("base", red(), red())]);
add(&mut v, "mx", mix(lit("#804020"), src("base"), 0.5));
let mut doc = Document::new("x");
doc.variables = v;
doc.map_colors(|c| c.with_alpha(0.5));
let mx = doc.variables.variables.iter().find(|x| x.id == "mx").unwrap();
let VariableValue::ColorMix { from, .. } = &mx.values["light"] else { panic!("still the mix") };
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
}
+25 -17
View File
@@ -18,7 +18,7 @@ use figcraft_doc::proto::Action;
use figcraft_doc::{
Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink,
LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId,
VariableValue, Variables, node_images, paint_images, used_images,
Variables, node_images, paint_images, used_images,
};
use serde_json::Value;
@@ -249,7 +249,7 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
}
}
/// `seeds` (variable ids) and every variable they alias, transitively, by id.
/// `seeds` (variable ids) and every variable they alias or calculate from, transitively, by id.
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
let mut out = BTreeSet::new();
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
@@ -259,8 +259,8 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>)
continue;
}
for val in v.values.values() {
if let VariableValue::Alias(a) = val {
stack.push(a.clone());
for r in val.references() {
stack.push(r.clone());
}
}
}
@@ -758,15 +758,19 @@ pub fn import_variables(
let Some(collection) = out.collections.get(&v.collection).cloned() else { continue };
let mut values = BTreeMap::new();
for (mode, val) in &v.values {
let val = match val {
// An alias to a variable the library doesn't have resolves to nothing: dropped
// (never pointed at a variable of this file).
VariableValue::Alias(a) => match src.get_by_id(a).and_then(|t| out.variables.get(&t.id)) {
Some(l) => VariableValue::Alias(l.clone()),
None => continue,
},
other => other.clone(),
};
// A value reading a variable the library doesn't have resolves to nothing: dropped
// (never pointed at a variable of this file), for aliases and calculations alike.
let mut val = val.clone();
let remapped = val.references_mut().into_iter().all(|r| match src.get_by_id(r.as_str()).and_then(|t| out.variables.get(&t.id)) {
Some(here) => {
*r = here.clone();
true
}
None => false,
});
if !remapped {
continue;
}
values.insert(mode.clone(), val);
}
let copy = Variable { id: local.clone(), collection, values, extends: None, overrides: Default::default(), ..v.clone() };
@@ -1044,10 +1048,14 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> {
.flat_map(|n| n.bound.values().map(String::as_str).chain(action_variables(n)))
.chain(doc.variables.variables.iter().filter_map(|v| v.extends.as_deref()))
.map(str::to_string)
.chain(doc.variables.variables.iter().filter(|v| !is_remote_variable(doc, &v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
VariableValue::Alias(a) => Some(a.clone()),
_ => None,
}))
.chain(
doc.variables
.variables
.iter()
.filter(|v| !is_remote_variable(doc, &v.id))
.flat_map(|v| v.values.values())
.flat_map(|x| x.references().into_iter().cloned()),
)
.collect::<Vec<_>>();
let used = alias_closure(&doc.variables, seeds);
let unused: BTreeSet<String> =
+11 -10
View File
@@ -150,12 +150,15 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
// Variables: by collection and variable name and type; collections and modes by name.
let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default();
// Variables the document's own layers bind or its own variables alias.
// Variables the document's own layers bind or its own variables alias or calculate from.
let remote: BTreeSet<String> = doc.libraries.variables.iter().map(|r| r.local.clone()).collect();
let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
VariableValue::Alias(a) => Some(a.clone()),
_ => None,
});
let aliases = doc
.variables
.variables
.iter()
.filter(|v| !remote.contains(&v.id))
.flat_map(|v| v.values.values())
.flat_map(|x| x.references().into_iter().cloned());
let used: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| n.bound.values().cloned()).chain(aliases).collect();
let var_recs: Vec<ImportedRecord> = doc.libraries.variables.iter().filter(|r| r.library == from && used.contains(&r.local)).cloned().collect();
let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published));
@@ -233,11 +236,9 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
.collect();
}
for v in doc.variables.variables.iter_mut().filter(|v| !remote.contains(&v.id)) {
for val in v.values.values_mut() {
if let VariableValue::Alias(a) = val
&& let Some(b) = vars.get(a)
{
*a = b.clone();
for r in v.values.values_mut().flat_map(VariableValue::references_mut) {
if let Some(b) = vars.get(r.as_str()) {
*r = b.clone();
}
}
}
+39 -5
View File
@@ -57,17 +57,23 @@ fn main_of(s: &Session, inst: NodeId) -> NodeId {
}
}
#[test]
fn swap_moves_instances_styles_and_variables_by_name() {
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{}", std::process::id())));
/// A session with libraries A (blue, plus `extra_a` components) and B (green) enabled, files in a
/// scratch directory named by `tag`; returns the directory guard, the session and both library ids.
fn two_libraries(tag: &str, extra_a: &[&str]) -> (Scratch, Session, String, String) {
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{tag}-{}", std::process::id())));
std::fs::create_dir_all(&dir.0).unwrap();
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
let a = library(&pa, "#0000ff", &["Only in A"]);
let a = library(&pa, "#0000ff", extra_a);
let b = library(&pb, "#00ff00", &[]);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": pa}));
ex(&mut s, "library.add", json!({"path": pb}));
(dir, s, a, b)
}
#[test]
fn swap_moves_instances_styles_and_variables_by_name() {
let (_dir, mut s, a, b) = two_libraries("main", &["Only in A"]);
let inst = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Button", "x": 0, "y": 0})));
let label = s.doc().unwrap().node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"}));
@@ -146,3 +152,31 @@ fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() {
assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b)));
assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b)));
}
/// A local colour calculation reading a library variable moves to the other library's variable of
/// the same name, as a binding does.
#[test]
fn swap_repoints_colour_calculations() {
let (_dir, mut s, a, b) = two_libraries("calc", &[]);
let accent = ex(&mut s, "library.importVariable", json!({"library": a, "variable": "accent"}))["variable"].clone();
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
ex(
&mut s,
"variable.create",
json!({"collection": "App", "name": "veil", "type": "color", "value": {"alpha": {"variable": accent, "opacity": 0.5}}}),
);
let wash = json!({"mix": {"from": {"variable": accent}, "to": {"literal": "#ffffff"}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "App", "name": "wash", "type": "color", "value": wash}));
let r = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
let doc = s.doc().unwrap();
let vars = &doc.variables;
let shown = |name: &str| match vars.resolve(&vars.get(name).unwrap().id, &Default::default()) {
Some(VariableValue::Color(c)) => c.to_css_hex(),
other => panic!("{name}: {other:?}"),
};
assert_eq!((shown("veil"), shown("wash")), ("#00FF0080".to_string(), "#80FF80".to_string()));
let reads: Vec<&VariableId> = ["veil", "wash"].iter().flat_map(|n| vars.get(n).unwrap().values.values()).flat_map(|v| v.references()).collect();
assert!(reads.iter().all(|r| doc.libraries.variable_import(r).is_some_and(|i| i.library == b)), "{reads:?}");
}
+1 -1
View File
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.colorProfile",
"Color profile",
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance — a colour calculation's mix keeps its ends exact and its colours in between move slightly; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
true,
color_profile
),
+38 -14
View File
@@ -164,21 +164,21 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.create",
"Create variable",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?} a value may also be {alias: variable} (same type); a colour value may also be {alpha: {variable, opacity 0..1}} (that variable's colour with its alpha replaced by the opacity) or {mix: {from, to, t}} (operands {variable: variable} or {literal: colour}; t is 0..1, 0 gives from and 1 to; blended per channel over the stored values, in the document's colour profile, including alpha; converting the profile keeps a mix's ends exact and moves its colours in between slightly), each alone in its object",
true,
var_create
),
spec!(
"variable.set",
"Set variable value",
"{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops)}",
"{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops) | {alpha} | {mix} (colour variables; shapes as in variable.create; no loops)}",
true,
var_set
),
spec!(
"variable.delete",
"Delete variable",
"{variable} (bound layers keep their values; variables aliasing it take its values)",
"{variable} (bound layers keep their values; variables aliasing it take its values; colour calculations reading it keep the colour they show)",
true,
|s, p| {
let v = req_string("variable.delete", p, "variable")?;
@@ -881,18 +881,31 @@ pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
if let Some(a) = v.get("alias").and_then(Value::as_str) {
return Some(VariableValue::Alias(a.to_string()));
/// A `value`/`values` entry of `kind`: a plain value of that type, `{alias: variable}`, or (colour
/// variables) an `{alpha}`/`{mix}` calculation. An alias stays a name here — the caller binds it to
/// an id; a calculation binds its own operands as they are checked. `for_id` is the variable being
/// set when it already exists (a calculation may not lead back to it).
fn var_value(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result<VariableValue> {
// Calculations first: they refuse sibling keys, so `{alias, alpha}` is an error rather than
// quietly one of the two.
if v.get("alpha").is_some() || v.get("mix").is_some() {
return super::variables::colour_calc(cmd, vars, kind, for_id, v);
}
Some(match kind {
VariableType::Color => VariableValue::Color(color_of(v)?),
VariableType::Number => VariableValue::Number(f64_of(v)?),
if let Some(a) = v.get("alias").and_then(Value::as_str) {
return Ok(VariableValue::Alias(a.to_string()));
}
Ok(match kind {
VariableType::Color => VariableValue::Color(color_of(v).ok_or_else(|| bad(cmd, "invalid value"))?),
VariableType::Number => VariableValue::Number(f64_of(v).ok_or_else(|| bad(cmd, "invalid value"))?),
VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())),
VariableType::Boolean => VariableValue::Boolean(v.as_bool()?),
VariableType::Boolean => VariableValue::Boolean(v.as_bool().ok_or_else(|| bad(cmd, "invalid value"))?),
})
}
/// `variable.create`: a variable with a value for every mode of its collection (`values` by mode,
/// then `value` for the modes left, then its type's default). Every value is checked before the
/// variable exists (alias targets, calculation operands, the 32-variable limit), so a refused one
/// leaves nothing behind.
fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.create";
let c = req_string(cmd, p, "collection")?;
@@ -915,11 +928,11 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
.find(|x| x.id == *mode || x.name == *mode)
.map(|x| x.id.clone())
.ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?;
values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?);
values.insert(mid, var_value(cmd, &doc.variables, kind, None, v)?);
}
}
if let Some(v) = get(p, "value") {
let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?;
let val = var_value(cmd, &doc.variables, kind, None, v)?;
for m in &coll.modes {
values.entry(m.id.clone()).or_insert_with(|| val.clone());
}
@@ -936,6 +949,10 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
*a = t.id.clone();
}
}
// The new variable has no id yet and nothing reads it, so any stand-in id will do.
for (m, val) in &values {
super::variables::check_resolves(cmd, &doc.variables, "", &coll.id, m, val)?;
}
let id = doc.new_variable_id();
doc.variables.variables.push(Variable {
id: id.clone(),
@@ -953,6 +970,10 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id}))
}
/// `variable.set`: one mode's value (the default mode when none is named), checked against the
/// variable as it stands before anything changes: an alias or calculation may not lead back to it,
/// and a calculation must fit the 32-variable limit. On a variable an extended collection inherits,
/// the value becomes that mode's override.
pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.set";
let v = req_string(cmd, p, "variable")?;
@@ -963,10 +984,12 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
};
let val = match get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))? {
let raw = get(p, "value").ok_or_else(|| bad(cmd, "invalid value"))?;
let val = match var_value(cmd, &doc.variables, var.kind, Some(&var.id), raw)? {
VariableValue::Alias(a) => VariableValue::Alias(super::variables::alias_target(&doc.variables, &var, &a).map_err(|e| bad(cmd, e))?),
v => v,
};
super::variables::check_resolves(cmd, &doc.variables, &var.id, &var.collection, &mode, &val)?;
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
// An inherited variable's value of its own overrides the inherited one in that mode.
if slot.extends.is_some() {
@@ -1206,7 +1229,8 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
VariableValue::Number(x) => json!(x),
VariableValue::String(x) => json!(x),
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(_) => continue,
// Resolved values are plain, so these never occur here.
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
};
sets.insert(field, jv);
}
+140 -22
View File
@@ -5,8 +5,8 @@
use std::collections::BTreeSet;
use figcraft_doc::{
CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables,
normalize_scopes, scope_known,
CODE_PLATFORMS, Collection, CollectionId, ColorOperand, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue,
Variables, normalize_scopes, scope_known,
};
use serde_json::{Value, json};
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.deleteCollection",
"Delete collection",
"{collection} (with its variables; bound layers keep their values)",
"{collection} (with its variables; bound layers keep their values; variables elsewhere aliasing them take their values and colour calculations reading them keep the colour they show)",
true,
delete_collection
),
@@ -296,17 +296,21 @@ fn delete_collection(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Delete variables `gone`: layers bound to them keep the values they show but no longer follow
/// them, and variables aliasing them take their values (per mode: the aliased variable's value in
/// that mode when it's in the same collection, else in its default mode).
/// them; a variable aliasing one takes its value and a colour calculation reading one keeps the
/// colour it shows (per mode: what the reader resolves to in that mode, other collections in their
/// default modes).
pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>) -> Result<()> {
// Every reader is resolved before anything is written, so a reader of a reader resolves
// through the values as they were.
let vars = &doc.variables;
let mut detached: Vec<(VariableId, ModeId, VariableValue)> = Vec::new();
for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) {
for (m, val) in &v.values {
let VariableValue::Alias(a) = val else { continue };
let Some(t) = vars.get_by_id(a).filter(|t| gone.contains(&t.id)) else { continue };
let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]);
if let Some(r) = vars.resolve(&t.id, &modes) {
if !val.references().iter().any(|r| gone.contains(*r)) {
continue;
}
let modes = std::collections::BTreeMap::from([(v.collection.clone(), m.clone())]);
if let Some(r) = vars.resolve(&v.id, &modes) {
detached.push((v.id.clone(), m.clone(), r));
}
}
@@ -429,6 +433,32 @@ pub fn sync_extensions(doc: &mut Document) {
}
}
/// Why a variable can't be read by an alias or a calculation.
enum TargetError {
Missing,
Kind(VariableType),
Loop,
}
/// The id of `target` (the variable a value would read, None when there is none) as something a
/// value may read: it is of `kind` and doesn't lead back to `from` (the variable being set, once it
/// exists).
fn reference_target(
vars: &Variables,
target: Option<&Variable>,
kind: VariableType,
from: Option<&VariableId>,
) -> std::result::Result<VariableId, TargetError> {
let t = target.ok_or(TargetError::Missing)?;
if t.kind != kind {
return Err(TargetError::Kind(t.kind));
}
if reaches_back(vars, &t.id, from) {
return Err(TargetError::Loop);
}
Ok(t.id.clone())
}
/// Check that `var` may alias `target` (id or name): it exists, has the same type and doesn't
/// lead back to `var`. Returns the target's id.
pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
@@ -438,28 +468,116 @@ pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::resu
/// Check an alias whose target is already a stored or remapped id, without matching names.
pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
let t = vars.get_by_id(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
if t.kind != var.kind {
return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id()));
}
let mut stack = vec![t.id.clone()];
reference_target(vars, vars.get_by_id(target), var.kind, Some(&var.id)).map_err(|e| match e {
TargetError::Missing => format!("no variable `{target}` to alias"),
TargetError::Kind(k) => format!("can't alias a {} variable to a {} one", var.kind.id(), k.id()),
TargetError::Loop => "the alias would refer back to itself".into(),
})
}
/// Whether what `seed` references (aliases and colour-calculation operands, in every mode) can
/// lead back to `only`. Bounded at 10,000 variables for hand-crafted files with bigger graphs.
fn reaches_back(vars: &Variables, seed: &VariableId, only: Option<&VariableId>) -> bool {
let Some(only) = only else { return false };
let mut stack = vec![seed.clone()];
let mut seen = BTreeSet::new();
while let Some(cur) = stack.pop() {
if cur == var.id {
return Err("the alias would refer back to itself".into());
if cur == *only {
return true;
}
if !seen.insert(cur.clone()) || seen.len() > 10_000 {
continue;
}
for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() {
if let VariableValue::Alias(a) = v
&& let Some(next) = vars.get_by_id(a)
{
stack.push(next.id.clone());
}
stack.extend(v.references().into_iter().cloned());
}
}
Ok(t.id.clone())
false
}
/// `{"alpha": …}` / `{"mix": …}` values: a colour derived from what it reads, checked the moment it
/// is set (unknown operand, non-colour operand, bad parameter, a reference back to the variable
/// being defined). Names bind to ids here, the way aliases bind at the call sites.
pub(crate) fn colour_calc(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result<VariableValue> {
if kind != VariableType::Color {
return Err(bad(cmd, "colour calculations only fit colour variables"));
}
// One operation per value: a second key beside it (another operation, `alias`, a typo) would
// otherwise be dropped without a word.
let obj = v.as_object().filter(|o| o.len() == 1).ok_or_else(|| bad(cmd, "a colour calculation is {alpha: …} or {mix: …} on its own"))?;
match obj.get("alpha") {
Some(a) => {
// Only these two keys: an extra one (a typo beside `opacity`, an option this doesn't take)
// must not be dropped without a word.
let a = a
.as_object()
.filter(|a| a.keys().all(|k| k == "variable" || k == "opacity"))
.ok_or_else(|| bad(cmd, "alpha: {variable, opacity}"))?;
let name = a.get("variable").and_then(Value::as_str).ok_or_else(|| bad(cmd, "alpha needs the variable to fade"))?;
// Parameters before operands: their errors don't depend on what the operands are.
let opacity = unit(cmd, a, "opacity")?;
Ok(VariableValue::ColorAlpha { variable: calc_operand(cmd, vars, for_id, name)?, opacity })
}
None => match obj.get("mix") {
Some(m) => {
let m = m
.as_object()
.filter(|m| m.keys().all(|k| matches!(k.as_str(), "from" | "to" | "t")))
.ok_or_else(|| bad(cmd, "mix: {from, to, t}"))?;
let t = unit(cmd, m, "t")?;
Ok(VariableValue::ColorMix {
from: mix_operand(cmd, vars, for_id, m.get("from"))?,
to: mix_operand(cmd, vars, for_id, m.get("to"))?,
t,
})
}
None => Err(bad(cmd, "not a colour calculation")),
},
}
}
/// A calculation whose operands all exist can still read more variables than one resolve may (a
/// mix of two long alias chains): refused when it is set, rather than left showing nothing. It is
/// checked in `mode` with every other collection in its default mode, the modes exports write it
/// in; another mode of a collection it reads, or a later change to what it reads, can still take
/// it past the limit, as an alias chain can.
pub(crate) fn check_resolves(cmd: &str, vars: &Variables, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Result<()> {
let calc = matches!(val, VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. });
if calc && vars.flattened_as(id, collection, mode, val).is_none() {
return Err(bad(cmd, format!("the colour calculation reads more than {} variables", Variables::RESOLVE_LIMIT)));
}
Ok(())
}
/// `obj[key]` as a number from 0 to 1. Strict, unlike `fraction_of` (which reads numbers past 1 as
/// percentages): a mistyped 1.5 is refused instead of being stored as 1.5%. -0.0 is stored as 0.0
/// so saved files read cleanly.
fn unit(cmd: &str, obj: &serde_json::Map<String, Value>, key: &str) -> Result<f32> {
let x = obj.get(key).and_then(Value::as_f64).filter(|x| (0.0..=1.0).contains(x));
let x = x.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from 0 to 1")))?;
Ok(if x == 0.0 { 0.0 } else { x as f32 })
}
/// A calculation operand given by name: an existing colour variable, never one that leads back to
/// the variable being set. Returns its id.
fn calc_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, name: &str) -> Result<VariableId> {
reference_target(vars, vars.get(name), VariableType::Color, for_id).map_err(|e| match e {
TargetError::Missing => bad(cmd, format!("no variable `{name}` to calculate from")),
TargetError::Kind(k) => bad(cmd, format!("colour calculations take colour variables: `{name}` is a {} one", k.id())),
TargetError::Loop => bad(cmd, "the colour calculation would refer back to itself"),
})
}
/// A mix operand: `{variable: name-or-id}` or `{literal: colour}` — a bare string is a colour name
/// as easily as a variable name, so the kind is always spelled out.
fn mix_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, v: Option<&Value>) -> Result<ColorOperand> {
let shape = || bad(cmd, "mix operands are {variable: …} or {literal: colour}");
let o = v.and_then(Value::as_object).filter(|o| o.len() == 1).ok_or_else(shape)?;
if let Some(name) = o.get("variable").and_then(Value::as_str) {
return Ok(ColorOperand::Variable { variable: calc_operand(cmd, vars, for_id, name)? });
}
let literal = crate::params::color_of(o.get("literal").ok_or_else(shape)?).ok_or_else(|| bad(cmd, "mix literal must be a colour"))?;
Ok(ColorOperand::Literal { literal })
}
/// The variable scope of bindable property `field` on `node` (None: any variable of the type fits).
+9 -4
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.export",
"Export variables",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads, colour calculations as the colour they show in each mode with other collections in their default modes | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"], colour calculations as the colour they show in that mode with other collections in their default modes; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file; each other mode as in css; colour calculations as the colour they show in that mode, other collections in their default modes) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases and colour calculations resolved), path? (write it there)} → {text}, or {path} when written",
false,
export
),
@@ -67,9 +67,10 @@ fn css_safe(text: &str) -> String {
/// Each collection's variables as custom properties (named as generated code names them): the
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
/// aliases are `var(--name)`.
/// aliases are `var(--name)` and colour calculations resolve to their colour in the mode.
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
let vars = &doc.variables;
let flattener = vars.flattener();
// Tailwind theme names, unique per file and the same ones page code reads.
let theme = crate::tailwind::Theme::new(doc);
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
@@ -89,7 +90,11 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
};
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
let value = match v.values.get(&m.id) {
// A calculation is written as the colour it resolves to in this mode (no color-mix()
// or relative-colour output), and so is an alias to one some mode leaves out:
// `var()` of a property no block defines would invalidate whatever reads it.
let flat = v.values.get(&m.id).and_then(|x| flattener.value(v, &m.id, x));
let value = match flat.as_ref() {
Some(VariableValue::Color(col)) => col.to_css(),
Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x),
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
@@ -99,7 +104,7 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
Some(t) => format!("var({})", name(t)),
None => continue,
},
None => continue,
Some(VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => continue,
};
out.push_str(&format!(" {}: {value};\n", name(v)));
}
+2
View File
@@ -567,6 +567,8 @@ mod variable_tests;
#[cfg(test)]
mod variables_behaviour_tests;
#[cfg(test)]
mod variables_colour_tests;
#[cfg(test)]
mod variables_editor_tests;
#[cfg(test)]
mod variables_import_shapes_tests;
@@ -264,6 +264,67 @@ fn bind_alias_mode_read_only_and_updates() {
let _ = std::fs::remove_dir_all(&d);
}
/// A library's colour calculations come in reading this file's copies of their operands (the
/// library's ids mean nothing here), keep following the library's updates, and keep the copies they
/// read when the library is removed.
#[test]
fn library_colour_calculations_read_the_copies() {
let d = dir("calc");
let lib_path = path_str(&d.join("theme.figcraft"));
let mut lib = Session::new();
theme(&mut lib);
ex(
&mut lib,
"variable.create",
json!({"collection": "Theme", "name": "hover", "type": "color", "value": {"alpha": {"variable": "base", "opacity": 0.5}}}),
);
let tint = json!({"mix": {"from": {"variable": "primary"}, "to": {"literal": "#ffffff"}, "t": 0.5}});
ex(&mut lib, "variable.create", json!({"collection": "Theme", "name": "tint", "type": "color", "value": tint}));
let lid = publish_to(&mut lib, &lib_path);
let lib_ids: Vec<String> = lib.doc().unwrap().variables.variables.iter().map(|v| v.id.clone()).collect();
let mut s = Session::new();
// Local variables first, so the copies get ids the library's don't share.
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
for name in ["one", "two", "three", "four", "five", "six", "seven"] {
ex(&mut s, "variable.create", json!({"collection": "App", "name": name, "type": "number", "value": 1}));
}
ex(&mut s, "library.add", json!({"path": lib_path}));
let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "hover", "field": "fill", "ids": [a]}));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "tint", "field": "fill", "ids": [b]}));
let shown = |s: &Session, id: NodeId| Some(crate::paint_tests::fill(s, id).to_css_hex());
assert_eq!((shown(&s, a), shown(&s, b)), (Some("#00FF0080".into()), Some("#8080FF".into())));
let (base, primary) = (copy_of(&s, "base").id, copy_of(&s, "primary").id);
assert!(!lib_ids.contains(&base) && !lib_ids.contains(&primary), "copies have ids of their own");
let reads = |name: &str| copy_of(&s, name).values.values().flat_map(|v| v.references().into_iter().cloned()).collect::<Vec<_>>();
assert_eq!(reads("hover"), [base.clone(), base]);
assert_eq!(reads("tint"), [primary.clone(), primary.clone()]);
ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#ff0000"}));
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#000000"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!((shown(&s, a), shown(&s, b)), (Some("#FF000080".into()), Some("#808080".into())));
// A local calculation is the only reader of `primary` once the layers let go of the library.
ex(
&mut s,
"variable.create",
json!({"collection": "App", "name": "wash", "type": "color", "value": {"mix": {"from": {"variable": primary}, "to": {"literal": "#ffffff"}, "t": 0.5}}}),
);
ex(&mut s, "variable.bind", json!({"ids": [a, b], "field": "fill", "variable": null}));
ex(&mut s, "library.remove", json!({"id": lid}));
let doc = s.doc().unwrap();
assert!(doc.variables.get_by_id(&primary).is_some(), "the copy a local calculation reads stays");
let wash = doc.variables.get("wash").unwrap();
let Some(VariableValue::Color(c)) = doc.variables.resolve(&wash.id, &Default::default()) else { panic!("wash resolves to a colour") };
assert_eq!(c.to_rgba8(), [128, 128, 128, 255]);
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_components_keep_their_variable_bindings() {
let d = dir("components");
+1 -1
View File
@@ -13,7 +13,7 @@ fn red_rect(s: &mut Session) -> figcraft_doc::NodeId {
r
}
fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
pub(crate) fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap()
}
@@ -15,7 +15,7 @@ fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn vid(v: Value) -> String {
pub(crate) fn vid(v: Value) -> String {
v["id"].as_str().unwrap().to_string()
}
+510
View File
@@ -0,0 +1,510 @@
//! Colour calculations: `{alpha: …}` (a referenced colour with its alpha replaced) and `{mix: …}`
//! (a blend of two colour operands) as per-mode variable values — set and create shapes, lazy
//! propagation to bound layers and mode switches, composition over aliases and other
//! calculations, set-time refusal of cycles and bad operands (leaving the variable untouched),
//! deletion flattening, undo/redo exactness, save/load round trip, exports and junk input.
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::error::EngineError;
use crate::paint_tests::fill;
use crate::plugins_tests::rect;
use crate::variables_behaviour_tests::vid;
use crate::widgets_tests::err;
use figcraft_color::Color;
/// A "C" collection (modes M) with `ink` #FF0000 and `edge` #00FF00 (the error-case set's seeds).
fn seeds() -> Session {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "C", "modes": ["M"]}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "ink", "type": "color", "value": "#FF0000"}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "edge", "type": "color", "value": "#00FF00"}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "size", "type": "number", "value": 5}));
s
}
#[test]
fn create_accepts_alpha_and_mix_and_they_propagate_to_bound_layers() {
let mut s = seeds();
let hover = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
));
let box_id = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [box_id], "field": "fill", "variable": "hover"}));
assert_eq!(fill(&s, box_id).to_rgba8(), [255, 0, 0, 128], "alpha replaces the source's alpha");
ex(&mut s, "variable.set", json!({"variable": "ink", "value": "#00AA00"}));
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 128], "the bound layer follows the source, no re-set needed");
// A mix of two variables, one literal, and the t edges.
ex(&mut s, "variable.set", json!({"variable": hover, "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.0}}}));
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 255], "t=0 gives from exactly");
ex(
&mut s,
"variable.set",
json!({"variable": hover, "value": {"mix": {"from": {"literal": "#000000"}, "to": {"literal": "#FFFFFF"}, "t": 0.4}}}),
);
assert_eq!(fill(&s, box_id).to_rgba8(), [102, 102, 102, 255], "literal operands blend (0.4 -> 0x66)");
// Compose: a mix reading the calculation, and alpha over an alias.
ex(&mut s, "variable.create", json!({"collection": "C", "name": "mirror", "type": "color", "value": {"alias": "ink"}}));
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "faded-mirror", "type": "color", "value": {"alpha": {"variable": "mirror", "opacity": 0.5}}}),
);
let b2 = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b2], "field": "fill", "variable": "faded-mirror"}));
assert_eq!(fill(&s, b2).to_rgba8(), [0, 170, 0, 128], "alpha over an alias reads the aliased value");
let toast = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "toast", "type": "color", "value": {"alpha": {"variable": "hover", "opacity": 0.25}}}),
));
let b3 = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b3], "field": "fill", "variable": toast}));
assert_eq!(fill(&s, b3).to_rgba8(), [102, 102, 102, 64], "a calculation may read a calculation");
}
#[test]
fn per_mode_operands_follow_each_collection_chosen_mode() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Core", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.createCollection", json!({"name": "Extra", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Core", "name": "p", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}));
ex(&mut s, "variable.create", json!({"collection": "Extra", "name": "s", "type": "color", "values": {"Light": "#000000", "Dark": "#FFFFFF"}}));
ex(
&mut s,
"variable.create",
json!({"collection": "Core", "name": "panel", "type": "color",
"values": {"Light": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}},
"Dark": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}}}}),
);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "panel"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 0, 0, 255], "Light mixes Light values of BOTH collections");
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Core", "mode": "Dark"}));
assert_eq!(fill(&s, b).to_rgba8(), [0, 0, 128, 255], "a Core switch moves only the Core operand (Extra stays Light)");
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Extra", "mode": "Dark"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 128, 255, 255], "the Extra switch then moves its operand, independently");
}
#[test]
fn bad_shapes_and_operands_are_refused_without_touching_the_variable() {
let mut s = seeds();
let before = json!(s.doc().unwrap().variables);
let want_t = "`t` must be a number from 0 to 1";
let want_opacity = "`opacity` must be a number from 0 to 1";
let want_operand = "mix operands are {variable: …} or {literal: colour}";
let cases: Vec<(&str, Value, &str)> = vec![
(
"self alpha",
json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
"the colour calculation would refer back to itself",
),
(
"unknown operand",
json!({"variable": "ink", "value": {"alpha": {"variable": "ghost", "opacity": 0.5}}}),
"no variable `ghost` to calculate from",
),
(
"number operand",
json!({"variable": "ink", "value": {"alpha": {"variable": "size", "opacity": 0.5}}}),
"colour calculations take colour variables: `size` is a number one",
),
(
"number mix operand",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "size"}, "to": {"variable": "edge"}, "t": 0.5}}}),
"colour calculations take colour variables: `size` is a number one",
),
(
"calculation on a number variable",
json!({"variable": "size", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
"colour calculations only fit colour variables",
),
("t too big", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 1.5}}}), want_t),
("t missing", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}}}}), want_t),
(
"t as text",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": "half"}}}),
want_t,
),
("opacity above 1", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 2}}}), want_opacity),
("opacity missing", json!({"variable": "ink", "value": {"alpha": {"variable": "edge"}}}), want_opacity),
("alpha on a literal", json!({"variable": "ink", "value": {"alpha": {"literal": "#FF0000", "opacity": 0.5}}}), "alpha: {variable, opacity}"),
("mix with a bare string", json!({"variable": "ink", "value": {"mix": {"from": "ink", "to": {"variable": "edge"}, "t": 0.5}}}), want_operand),
(
"mix operand with two keys",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink", "literal": "#000000"}, "to": {"variable": "edge"}, "t": 0.5}}}),
want_operand,
),
("unknown key in alpha", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opactiy": 0.5}}}), "alpha: {variable, opacity}"),
// A bad parameter is named even beside an operand that loops: it is checked first.
(
"bad t beside a loop",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 2}}}),
want_t,
),
("bad opacity beside a loop", json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": -1}}}), want_opacity),
];
for (label, p, want) in cases {
let msg = err(&mut s, "variable.set", p.clone());
assert!(msg.contains(want), "{label}: {msg}");
}
// A second key beside the operation is refused, not dropped.
let alpha = json!({"variable": "edge", "opacity": 0.5});
let mix = json!({"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5});
for (label, value) in [
("alpha and mix", json!({"alpha": alpha.clone(), "mix": mix.clone()})),
("alias beside alpha", json!({"alias": "edge", "alpha": alpha})),
("unknown key beside mix", json!({"mix": mix, "space": "oklch"})),
] {
let msg = err(&mut s, "variable.set", json!({"variable": "ink", "value": value}));
assert!(msg.contains("on its own"), "{label}: {msg}");
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "extra", "type": "color", "value": value}));
assert!(msg.contains("on its own"), "{label} on create: {msg}");
}
assert_eq!(json!(s.doc().unwrap().variables), before, "the refusals left every variable as it was");
// A cycle reached through binary operands too: ink mixes edge; then edge must not read ink.
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}));
let msg = err(&mut s, "variable.set", json!({"variable": "edge", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}));
assert!(msg.contains("the colour calculation would refer back to itself"), "cycle named as such: {msg}");
let msg = err(
&mut s,
"variable.set",
json!({"variable": "edge", "value": {"mix": {"from": {"variable": "ink"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}),
);
assert!(msg.contains("the colour calculation would refer back to itself"), "mix-cycle named as such: {msg}");
// None of the refusals touched the variables: ink still holds its last accepted value, and a
// fresh calculation on it works.
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "ink"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 255, 128, 255], "ink keeps the accepted mix (edge + white at 0.5)");
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 0.5}}}));
assert_eq!(fill(&s, b).to_rgba8(), [0, 255, 0, 128], "and calculations still set afterwards");
}
#[test]
fn undo_and_redo_are_exact_through_calculations() {
let mut s = seeds();
let hover = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
));
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": hover}));
let first = fill(&s, b).to_rgba8();
ex(&mut s, "variable.set", json!({"variable": "hover", "value": {"alpha": {"variable": "edge", "opacity": 0.25}}}));
let second = fill(&s, b).to_rgba8();
assert_ne!(first, second);
ex(&mut s, "edit.undo", json!({}));
assert_eq!(fill(&s, b).to_rgba8(), first, "undo restores the stored alpha calculation exactly");
ex(&mut s, "edit.redo", json!({}));
assert_eq!(fill(&s, b).to_rgba8(), second, "redo replays the new one exactly");
}
#[test]
fn deleting_a_source_flattens_its_calculations() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fade"}));
let before = json!(s.doc().unwrap().variables);
ex(&mut s, "variable.delete", json!({"variable": "ink"}));
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 128], "the bound layer keeps the colour it showed");
let fade: Vec<_> = s.doc().unwrap().variables.get("fade").unwrap().values.values().cloned().collect();
assert_eq!(fade, [figcraft_doc::VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.5))], "the calculation became the colour it showed");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores every variable exactly");
}
/// Deleting a collection at once flattens every reader outside it — an alias of a calculation, a
/// calculation of two deleted variables — and deleting a calculation flattens a calculation that
/// reads it; undo restores the variables exactly each time.
#[test]
fn deleting_sources_together_flattens_every_reader() {
let mut s = seeds();
let calc = |name: &str, value: Value| json!({"collection": "C", "name": name, "type": "color", "value": value});
ex(&mut s, "variable.create", calc("fade", json!({"alpha": {"variable": "ink", "opacity": 0.5}})));
ex(&mut s, "variable.create", calc("wash", json!({"mix": {"from": {"variable": "fade"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}})));
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
ex(&mut s, "variable.create", json!({"collection": "App", "name": "echo", "type": "color", "value": {"alias": "wash"}}));
let tint = json!({"mix": {"from": {"variable": "edge"}, "to": {"variable": "ink"}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "App", "name": "tint", "type": "color", "value": tint}));
let stored = |s: &Session, name: &str| s.doc().unwrap().variables.get(name).unwrap().values.values().next().cloned().unwrap();
let plain = |s: &Session, name: &str| match stored(s, name) {
figcraft_doc::VariableValue::Color(c) => c.to_rgba8(),
other => panic!("{name} is not a plain colour: {other:?}"),
};
let before = json!(s.doc().unwrap().variables);
ex(&mut s, "variable.deleteCollection", json!({"collection": "C"}));
assert_eq!(plain(&s, "echo"), [255, 128, 128, 191], "the alias of a calculation keeps what it showed");
assert_eq!(plain(&s, "tint"), [128, 128, 0, 255], "a calculation of two deleted variables keeps its colour");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores the collection and its readers exactly");
ex(&mut s, "variable.delete", json!({"variable": "fade"}));
assert_eq!(plain(&s, "wash"), [255, 128, 128, 191], "a calculation of the deleted calculation keeps its colour");
assert!(matches!(stored(&s, "echo"), figcraft_doc::VariableValue::Alias(_)), "an alias of a surviving variable stays an alias");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before);
}
/// One resolve reads at most `Variables::RESOLVE_LIMIT` variables: a mix of two alias chains that
/// would read more is refused when it is set rather than left showing nothing.
#[test]
fn a_calculation_reading_too_many_variables_is_refused() {
let mut s = seeds();
for side in ["a", "b"] {
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}0"), "type": "color", "value": {"alias": "ink"}}));
for i in 1..16 {
let value = json!({"alias": format!("{side}{}", i - 1)});
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}{i}"), "type": "color", "value": value}));
}
}
// Reading `a{k}` and `b{k}` takes 2k + 4 variables: itself, both chains and the `ink` they share.
let mix = |k: usize| json!({"mix": {"from": {"variable": format!("a{k}")}, "to": {"variable": format!("b{k}")}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "C", "name": "fits", "type": "color", "value": mix(14)}));
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fits"}));
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 255], "32 variables still resolve");
let too_many = "the colour calculation reads more than 32 variables";
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "over", "type": "color", "value": mix(15)}));
assert!(msg.contains(too_many), "create: {msg}");
let msg = err(&mut s, "variable.set", json!({"variable": "fits", "value": mix(15)}));
assert!(msg.contains(too_many), "set: {msg}");
}
/// Junk anywhere inside a calculation is refused cleanly: the variables exist, so it reaches the
/// calculation parsers (the command-wide sweep starts from a document without variables).
#[test]
fn calculation_parsers_survive_junk() {
let junk = [
json!(null),
json!(1e308),
json!(-0.0),
json!("NaN"),
json!("\u{0}"),
json!([]),
json!({}),
json!([1e308, -1e308, "x"]),
json!({"variable": [1]}),
json!({"variable": "ink", "literal": "#000"}),
json!({"literal": {"r": 1e308, "g": "x"}}),
json!({"mix": {"from": {"literal": {"mix": 1}}}}),
json!({"alpha": {"variable": "ink", "opacity": 0.5}}),
];
for j in &junk {
for value in [
json!({"alpha": j}),
json!({"mix": j}),
json!({"mix": {"from": j, "to": j, "t": j}}),
json!({"alpha": {"variable": j, "opacity": j}}),
json!({"alpha": {"variable": "ink", "opacity": j}}),
json!({"mix": {"from": {"variable": "ink"}, "to": {"literal": j}, "t": 0.5}}),
] {
for (cmd, p) in [
("variable.set", json!({"variable": "edge", "value": value})),
("variable.set", json!({"variable": "size", "value": value})),
("variable.create", json!({"collection": "C", "name": "junk", "type": "color", "values": {"M": value}})),
] {
let mut s = seeds();
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &p) {
panic!("{cmd} panicked on {p}: {msg}");
}
}
}
}
}
#[test]
fn calculations_survive_save_and_load() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "mixv", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.4}}}),
);
let dir = std::env::temp_dir().join(format!("figcraft-colour-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("d.figcraft");
ex(&mut s, "file.save", json!({"path": path.to_string_lossy()}));
let mut t = Session::new();
ex(&mut t, "file.open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_dir_all(&dir);
let list = ex(&mut t, "variable.list", json!({}));
let mixv = list["variables"].as_array().unwrap().iter().find(|v| v["name"] == "mixv").unwrap();
assert_eq!(mixv["values"].as_object().unwrap().values().next().unwrap()["type"], "colorMix", "still a calculation after a round trip");
// And it still propagates in the reloaded document.
let b = idv(&ex(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4})));
ex(&mut t, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "mixv"}));
assert_eq!(fill(&t, b).to_rgba8(), [153, 102, 0, 255], "ink + edge at 0.4: r=255*(1-0.4), g=255*0.4");
}
#[test]
fn rest_and_css_exports_write_calculations_as_their_colours() {
let mut s = seeds();
// The exported REST variable `name` of `collections`, and the CSS text for them.
let json_variable = |s: &mut Session, collections: Value, name: &str| -> Value {
let text = ex(s, "variable.export", json!({"format": "json", "collections": collections}));
let doc: Value = serde_json::from_str(text["text"].as_str().unwrap()).unwrap();
let meta = doc.get("meta").unwrap_or(&doc);
meta["variables"].as_object().unwrap().values().find(|v| v["name"] == name).unwrap().clone()
};
let css = |s: &mut Session, collections: Value| {
ex(s, "variable.export", json!({"format": "css", "collections": collections}))["text"].as_str().unwrap().to_string()
};
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
);
let fade = json_variable(&mut s, json!(["C"]), "fade");
let value = fade["valuesByMode"].as_object().unwrap().values().next().unwrap();
assert_eq!(value["r"], 1.0);
assert_eq!(value["a"], 0.5);
assert!(value.get("type").is_none(), "no calculation shape leaks into the REST JSON: {value}");
let text = css(&mut s, json!(["C"]));
assert!(text.contains("rgba(255, 0, 0, 0.5"), "the CSS export writes the colour it resolves to: {text}");
// Each mode is written as the colour it shows in that mode.
ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
ex(
&mut s,
"variable.create",
json!({"collection": "Theme", "name": "brand", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}),
);
let veil = json!({"alpha": {"variable": "brand", "opacity": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "veil", "type": "color", "value": veil}));
let veil = json_variable(&mut s, json!(["Theme"]), "veil");
let mut per_mode: Vec<(f64, f64, f64)> = veil["valuesByMode"]
.as_object()
.unwrap()
.values()
.map(|v| (v["r"].as_f64().unwrap(), v["b"].as_f64().unwrap(), v["a"].as_f64().unwrap()))
.collect();
per_mode.sort_by(|x, y| x.partial_cmp(y).unwrap());
assert_eq!(per_mode, [(0.0, 1.0, 0.5), (1.0, 0.0, 0.5)], "Light and Dark each written as their own colour");
let text = css(&mut s, json!(["Theme"]));
assert!(text.contains("rgba(255, 0, 0, 0.5") && text.contains("rgba(0, 0, 255, 0.5"), "both modes in CSS: {text}");
}
/// A hand-edited file can hold a calculation that resolves to nothing (one reading a variable
/// that isn't there). The CSS export leaves it out, so no property may name it: an alias to it
/// goes out as the colour it shows, or is left out when that is nothing.
#[test]
fn css_never_names_a_calculation_it_leaves_out() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]}));
let ink = json!({"collection": "T", "name": "ink", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}});
ex(&mut s, "variable.create", ink);
for name in ["lost", "half"] {
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": "#000000"}));
}
for (name, to) in [("lost-too", "lost"), ("half-too", "half"), ("ink-too", "ink")] {
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": {"alias": to}}));
}
// As a hand-edited file could hold them: `lost` reads a variable that isn't there in both
// modes, `half` only in Dark.
let doc = s.doc_mut().unwrap();
let ink = doc.variables.get("ink").unwrap().id.clone();
let modes: Vec<String> = doc.variables.collection("T").unwrap().modes.iter().map(|m| m.id.clone()).collect();
let gone = figcraft_doc::VariableValue::ColorAlpha { variable: "gone".into(), opacity: 0.5 };
let half = figcraft_doc::VariableValue::ColorAlpha { variable: ink, opacity: 0.5 };
for v in &mut doc.variables.variables {
match v.name.as_str() {
"lost" => v.values = modes.iter().map(|m| (m.clone(), gone.clone())).collect(),
"half" => v.values = [(modes[0].clone(), half.clone()), (modes[1].clone(), gone.clone())].into_iter().collect(),
_ => {}
}
}
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string();
let (light, dark) = css.split_once("[data-").unwrap();
assert!(!css.contains("var(--lost)") && !css.contains("var(--half)"), "no var() of a property a block leaves out: {css}");
assert!(!css.contains("--lost-too"), "an alias to what shows nothing is left out: {css}");
assert!(light.contains("--half-too: rgba(255, 0, 0, 0.5") && !dark.contains("--half-too"), "the colour it shows, where it shows one: {css}");
assert!(css.contains("--ink-too: var(--ink);"), "an alias that always resolves stays a reference: {css}");
}
/// A calculation is a variable's value, not a literal an expression may hold: nothing would check
/// what it reads. Every way an expression arrives refuses one.
#[test]
fn prototype_expressions_refuse_a_calculation_literal() {
let mut s = seeds();
let btn = rect(&mut s, 0.0);
let dest = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "proto.interaction.add", json!({"id": btn, "dest": dest}));
let calc = json!({"value": {"type": "colorAlpha", "value": {"variable": "ink", "opacity": 9}}});
let msg = err(&mut s, "proto.evaluate", json!({"expr": calc}));
assert!(msg.contains("Not a value"), "evaluate: {msg}");
let set = json!({"type": "setVariable", "variable": "edge", "value": null, "expr": calc});
let msg = err(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "action": set}));
assert!(msg.contains("Not a value"), "set variable action: {msg}");
let added = ex(
&mut s,
"proto.action.add",
json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [{"expression": "size > 1", "actions": []}]}}),
);
let same = json!({"op": {"op": "equals", "args": [{"value": {"type": "alias", "value": "ink"}}, calc]}});
let msg = err(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": added["path"], "block": 0, "condition": same}));
assert!(msg.contains("Not a value"), "condition: {msg}");
}
#[test]
fn rest_import_skips_a_hand_written_calculation_with_a_warning() {
// The variables JSON has no shape for calculations; a hand-written one must not import or corrupt.
let mut s = seeds();
let text = json!({
"variableCollections": {"COL:X": {"id": "COL:X", "name": "Imp", "modes": [{"modeId": "M:1", "name": "M"}], "defaultModeId": "M:1", "variableIds": ["V:9"]}},
"variables": {"V:9": {"id": "V:9", "name": "ghost", "variableCollectionId": "COL:X", "resolvedType": "COLOR",
"valuesByMode": {"M:1": {"type": "colorAlpha", "value": {"variable": "V:1", "opacity": 0.5}}}}}
});
let out = ex(&mut s, "variable.import", json!({"variables": text}));
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("composed colours")), "skip warning present: {out}");
let list = ex(&mut s, "variable.list", json!({}));
assert!(list["variables"].as_array().unwrap().iter().all(|v| v["name"] != "ghost"), "the calculation was skipped");
}
#[test]
fn profile_conversion_reaches_the_colours_stored_in_a_calculation() {
// A mix of identical literals shows that colour; after a converting profile change the
// mix-bound layer must match a layer bound to the plain colour (the literal converted too).
let mut s = seeds();
ex(&mut s, "variable.create", json!({"collection": "C", "name": "p", "type": "color", "value": "#804020"}));
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "dl", "type": "color", "value": {"mix": {"from": {"literal": "#804020"}, "to": {"literal": "#804020"}, "t": 0.5}}}),
);
let a = rect(&mut s, 0.0);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "p"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "dl"}));
ex(&mut s, "file.colorProfile", json!({"profile": "displayP3"}));
let (fa, fb) = (fill(&s, a), fill(&s, b));
assert_ne!(fa.to_rgba8(), [128, 64, 32, 255], "the profile change converted the plain colour");
assert_eq!(fa.to_rgba8(), fb.to_rgba8(), "the colour stored inside the calculation converted the same way");
}
#[test]
fn negative_zero_bounds_are_stored_as_plain_zero() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "n0", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": -0.0}}}),
);
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "m0", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": -0.0}}}),
);
let list = serde_json::to_string(&ex(&mut s, "variable.list", json!({}))).unwrap();
assert!(!list.contains("-0"), "no negative zeros persisted: {list}");
}
+31 -1
View File
@@ -5,7 +5,7 @@
//! laid out for it. Everything written reads back; what the format can't say is written as its
//! value with a warning; damaged files never panic.
use figcraft_doc::{Collection, Mode, Style, StyleKind, Variable, VariableType, VariableValue};
use figcraft_doc::{Collection, ColorOperand, Mode, Style, StyleKind, Variable, VariableType, VariableValue};
use serde_json::json;
use super::*;
@@ -383,6 +383,36 @@ fn bindings_the_format_cant_say_keep_their_value_with_a_warning() {
assert!(back.variables.get("lost").is_none());
}
/// A colour calculation has no encoding here: it is written as the colour it shows in each mode,
/// and one that can't resolve is left out with a warning rather than written as something else.
#[test]
fn colour_calculations_are_written_as_the_colour_they_show() {
let mut doc = themed();
let fade = |source: &str| VariableValue::ColorAlpha { variable: source.into(), opacity: 0.5 };
let mix = VariableValue::ColorMix {
from: ColorOperand::Variable { variable: "v-fg".into() },
to: ColorOperand::Literal { literal: Color::new(1.0, 0.0, 0.0, 1.0) },
t: 0.5,
};
doc.variables.variables.push(var("v-veil", "color/veil", "c-theme", VariableType::Color, &[("m-light", fade("v-bg")), ("m-dark", mix)]));
doc.variables.variables.push(var("v-broken", "color/broken", "c-theme", VariableType::Color, &[("m-light", fade("v-gone"))]));
let (bytes, warnings) = export(&doc, None).unwrap();
assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}");
let (back, _) = import(&bytes, "x").unwrap();
assert!(back.variables.get("color/broken").is_none_or(|v| v.values.is_empty()), "left out, not written as something else");
let theme = back.variables.collections.iter().find(|c| c.name == "Theme").unwrap();
let veil = back.variables.get("color/veil").unwrap();
let shown: Vec<String> = theme
.modes
.iter()
.map(|m| match veil.values.get(&m.id) {
Some(VariableValue::Color(c)) => c.to_css_hex(),
other => panic!("{}: {other:?}", m.name),
})
.collect();
assert_eq!(shown, ["#FFFFFF80", "#DF6060"]);
}
/// A style of each kind linked from a layer, and one that nothing uses.
fn styled() -> Document {
let mut doc = Document::new("Styled");
+9 -1
View File
@@ -211,7 +211,13 @@ impl<'d> Exporter<'d> {
let mut values = Vec::new();
for m in &c.modes {
let (Some(mode), Some(val)) = (self.mode_ids.get(&(c.id.as_str(), m.id.as_str())), v.values.get(&m.id)) else { continue };
let (ty, value) = match val {
// The Figma binary has no encoding FigCraft writes for calculations: the colour
// they show in this mode goes out as a plain colour value.
let Some(val) = doc.variables.flattened(v, &m.id, val) else {
self.warn(crate::CALCULATIONS_LEFT_OUT);
continue;
};
let (ty, value) = match &val {
VariableValue::Color(x) => ("COLOR", ("colorValue", color(*x))),
VariableValue::Number(x) => ("FLOAT", ("floatValue", fl(*x))),
// NULs can't be stored in a string.
@@ -224,6 +230,8 @@ impl<'d> Exporter<'d> {
continue;
}
},
// `flattened` leaves none: this format has no shape for a calculation.
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
};
let data = o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]);
values.push(o(vec![("modeID", guid(AUX, *mode)), ("variableData", data)]));
+3
View File
@@ -56,6 +56,9 @@ const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024;
const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024;
/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped.
pub const MAX_VERSIONS: usize = 100;
/// The export warning for colour calculations that resolve to nothing (an operand missing, or more
/// than 32 variables to read), shared by the Penpot and `.fig` exports.
pub(crate) const CALCULATIONS_LEFT_OUT: &str = "colour calculations that don't resolve were left out";
/// Longest version name and description kept (chars).
pub const MAX_VERSION_NAME: usize = 200;
pub const MAX_VERSION_DESCRIPTION: usize = 2000;
+17 -2
View File
@@ -1701,6 +1701,11 @@ impl<'a> Exporter<'a> {
(!out.is_empty()).then_some(Value::Object(out))
}
/// The archive's `tokens.json`: the file's own Penpot tokens when it came from Penpot and its
/// variables are unchanged since, else one token set per collection (per mode, with a theme
/// for each, when it has several). Aliases go out as `{name}` references and colour
/// calculations as the colour they show; a token that resolves to nothing is left out with a
/// warning, so no reference may name it.
fn tokens_json(&mut self) -> Option<String> {
let first = self.doc.pages.first().and_then(|p| self.node(*p));
let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone()));
@@ -1715,6 +1720,7 @@ impl<'a> Exporter<'a> {
if variables.variables.is_empty() {
return None;
}
let flattener = variables.flattener();
let mut root = Map::new();
let mut themes = Vec::new();
let mut order = Vec::new();
@@ -1726,8 +1732,15 @@ impl<'a> Exporter<'a> {
let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() };
let mut tokens = Map::new();
for var in variables.variables.iter().filter(|v| v.collection == collection.id) {
let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue };
let (kind, value) = match value {
let Some(value) = var.value_or_first(&mode.id) else { continue };
// Penpot tokens have no shape for colour calculations: the colour they resolve
// to in this mode goes out as a plain colour token, and so does an alias to one
// some set leaves out (its `{name}` would dangle whenever that set is active).
let Some(value) = flattener.value(var, &mode.id, value) else {
self.warn(crate::CALCULATIONS_LEFT_OUT);
continue;
};
let (kind, value) = match &value {
VariableValue::Alias(target) => {
let Some(target_name) = self.token_name(target) else { continue };
let kind = match var.kind {
@@ -1742,6 +1755,8 @@ impl<'a> Exporter<'a> {
let [r, g, b, a] = c.to_rgba8();
("color", json!(if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") }))
}
// `flattened` leaves none: this format has no shape for a calculation.
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
VariableValue::Number(n) => ("number", json!(num_text(*n))),
VariableValue::String(s) => ("string", json!(s)),
VariableValue::Boolean(b) => ("boolean", json!(b.to_string())),
+80 -12
View File
@@ -5,8 +5,9 @@ use figcraft_doc::color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMo
use figcraft_doc::geom::{Affine, Point, Shape};
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger};
use figcraft_doc::{
AutoLayout, Blur, Collection, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, NodeKind,
Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, VectorNetwork,
AutoLayout, Blur, Collection, ColorOperand, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId,
NodeKind, Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue,
VectorNetwork,
};
use serde_json::{Value, json};
@@ -523,15 +524,25 @@ fn variable(id: &str, name: &str, collection: &str, kind: VariableType, values:
}
}
#[test]
fn native_variables_become_token_sets_themes_and_applied_tokens() {
let mut doc = Document::new("Tokens");
doc.variables.collections.push(Collection {
/// A "Color" collection `c1` with Light and Dark modes.
fn light_dark_colors() -> Collection {
Collection {
id: "c1".into(),
name: "Color".into(),
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
extends: None,
});
}
}
/// The export's `tokens.json`.
fn tokens(bytes: &[u8]) -> Value {
entries(bytes).into_iter().find(|(name, _)| name.ends_with("tokens.json")).map(|(_, v)| v).unwrap()
}
#[test]
fn native_variables_become_token_sets_themes_and_applied_tokens() {
let mut doc = Document::new("Tokens");
doc.variables.collections.push(light_dark_colors());
doc.variables.collections.push(Collection {
id: "c2".into(),
name: "Size".into(),
@@ -560,11 +571,7 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() {
doc.node_mut(id).unwrap().bound.insert("width".into(), "v2".into());
let (bytes, _) = export(&doc).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
let tokens_path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned();
let mut text = String::new();
z.by_name(&tokens_path).unwrap().read_to_string(&mut text).unwrap();
let tokens: Value = serde_json::from_str(&text).unwrap();
let tokens = tokens(&bytes);
assert_eq!(tokens["Color/Light"]["brand"]["primary"]["$value"], "#ff0000");
assert_eq!(tokens["Color/Dark"]["brand"]["primary"]["$value"], "#880000");
assert_eq!(tokens["Color/Light"]["brand"]["alias"]["$value"], "{brand.primary}");
@@ -580,6 +587,67 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() {
assert!(bound.bound.contains_key("width"));
}
/// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows,
/// and one that resolves to nothing is left out with a warning. No reference names a token a set
/// leaves out: an alias to one goes out as the colour it shows, or is left out with it.
#[test]
fn colour_calculations_export_as_the_colour_they_show() {
let mut doc = Document::new("Calculated");
doc.variables.collections.push(light_dark_colors());
let hex = |h: &str| Color::from_hex(h).unwrap();
doc.variables.variables.push(variable(
"v1",
"brand/primary",
"c1",
VariableType::Color,
&[("light", VariableValue::Color(hex("#ff0000"))), ("dark", VariableValue::Color(hex("#0000ff")))],
));
let mix = VariableValue::ColorMix {
from: ColorOperand::Variable { variable: "v1".into() },
to: ColorOperand::Literal { literal: hex("#00ff0080") },
t: 0.5,
};
doc.variables.variables.push(variable(
"v2",
"brand/veil",
"c1",
VariableType::Color,
&[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)],
));
let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 };
doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone.clone())]));
// Aliases: to the lost one (same collection), to one lost only in Dark (from a collection with
// one mode, which reads Color in its default mode, Light), to an alias that is left out, and to
// one that always resolves.
let alias = |to: &str| VariableValue::Alias(to.into());
doc.variables.variables.push(variable("v4", "brand/lost-too", "c1", VariableType::Color, &[("light", alias("v3")), ("dark", alias("v3"))]));
let half = VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 };
doc.variables.variables.push(variable("v5", "brand/half", "c1", VariableType::Color, &[("light", half), ("dark", gone)]));
doc.variables.collections.push(Collection {
id: "c2".into(),
name: "Theme".into(),
modes: vec![Mode { id: "one".into(), name: "One".into() }],
extends: None,
});
doc.variables.variables.push(variable("v6", "theme/accent", "c2", VariableType::Color, &[("one", alias("v5"))]));
doc.variables.variables.push(variable("v7", "theme/veil", "c2", VariableType::Color, &[("one", alias("v2"))]));
doc.variables.variables.push(variable("v8", "theme/deep", "c2", VariableType::Color, &[("one", alias("v4"))]));
let (bytes, warnings) = export(&doc).unwrap();
assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}");
let tokens = tokens(&bytes);
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written");
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost-too").is_none()), "an alias to it too");
assert!(tokens["Color/Dark"]["brand"].get("half").is_none() && tokens["Color/Light"]["brand"]["half"]["$value"] == "#ff000080");
assert_eq!(tokens["Theme"]["theme"]["accent"]["$value"], "#ff000080", "the colour it shows, not {{brand.half}}");
assert_eq!(tokens["Theme"]["theme"]["veil"]["$value"], "{brand.veil}", "a reference that always resolves stays one");
assert!(tokens["Theme"]["theme"].get("deep").is_none(), "an alias to an alias that is left out is left out too");
let text = tokens.to_string();
assert!(!text.contains("{brand.lost") && !text.contains("{brand.half}"), "no reference to a token a set leaves out: {text}");
assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080");
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0");
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color");
}
#[test]
fn imported_penpot_tokens_are_written_back_verbatim() {
let mut doc = Document::new("Kept tokens");
+1 -1
View File
@@ -355,7 +355,7 @@ impl Ctx<'_> {
Some(VariableValue::Number(n)) => json!(n),
Some(VariableValue::String(s)) => json!(s),
Some(VariableValue::Boolean(b)) => json!(b),
Some(VariableValue::Alias(_)) | None => {
Some(VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => {
self.warn("set-variable actions with values that don't fit the variable were skipped");
return None;
}
+9 -3
View File
@@ -67,7 +67,8 @@ pub(super) fn value(kind: VariableType, v: &Value) -> Option<VariableValue> {
/// `collections` of `vars` (with their variables) as a local variables response, which
/// [`Ctx::variables`] reads back. Aliases to variables outside them are written as the value they
/// resolve to in the default modes.
/// resolve to in the default modes. Colour calculations have no shape in this JSON, so they go out
/// as the colour they resolve to in the mode they are written for.
pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
let ids: BTreeSet<&str> = collections.iter().map(|c| c.id.as_str()).collect();
let members: Vec<&Variable> = vars.variables.iter().filter(|v| ids.contains(v.collection.as_str())).collect();
@@ -91,7 +92,10 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
Some(r) => r,
None => continue,
},
other => other.clone(),
other => match vars.flattened(v, mode, other) {
Some(flat) => flat,
None => continue,
},
};
values.insert(mode.clone(), raw(&val));
}
@@ -112,7 +116,8 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
json!({"status": 200, "error": false, "meta": {"variableCollections": colls, "variables": out}})
}
/// A value as the variables JSON writes it (the inverse of [`value`]).
/// A value as the variables JSON writes it (the inverse of [`value`]). The export resolves
/// calculations before calling this, so they have no shape of their own here.
fn raw(v: &VariableValue) -> Value {
match v {
VariableValue::Color(c) => json!({"r": c.r, "g": c.g, "b": c.b, "a": c.a}),
@@ -120,6 +125,7 @@ fn raw(v: &VariableValue) -> Value {
VariableValue::String(s) => json!(s),
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(a) => json!({"type": "VARIABLE_ALIAS", "id": a}),
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => Value::Null,
}
}
+2
View File
@@ -240,6 +240,7 @@ name = "English"
"Convert to section" = "Convert to section"
"CSS custom properties…" = "CSS custom properties…"
"Dart constants for Flutter…" = "Dart constants for Flutter…"
"Calculated from other variables; it follows them" = "Calculated from other variables; it follows them"
"Can edit" = "Can edit"
"Can't read this library: {e}" = "Can't read this library: {e}"
"Cancel" = "Cancel"
@@ -424,6 +425,7 @@ name = "English"
"Desktop" = "Desktop"
"Destination" = "Destination"
"Detach alias" = "Detach alias"
"Detach calculation" = "Detach calculation"
"Detach instance" = "Detach instance"
"Detach property" = "Detach property"
"Detach style" = "Detach style"
+54 -25
View File
@@ -762,14 +762,13 @@ fn kind_glyph(ui: &Ui, vars: &Variables, v: &Variable, coll: &Collection, rect:
}
}
/// `val` as a `variable.set` value.
fn value_json(val: &VariableValue) -> Value {
match val {
VariableValue::Color(c) => json!(c.to_css_hex()),
VariableValue::Number(x) => json!(x),
VariableValue::String(s) => json!(s),
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(a) => json!({"alias": a}),
/// The button that replaces the reference or calculation in `mode` with the value it shows now.
fn detach(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &Collection, mode: &str, (key, at): (Id, Rect), label: &str) {
if widgets::icon_button_at(ui, key, at, Icon::Detach, label) {
let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect();
if let Some(resolved) = view.vars.resolve(&v.id, &modes) {
set(app, v, mode, crate::panels::proto_panel::value_json(&resolved));
}
}
}
@@ -821,12 +820,29 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
if resp.clicked() {
crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
}
// Detach: keep the value it resolves to now.
if hovered && widgets::icon_button_at(ui, id.with("detach"), alias_btn, Icon::Detach, tr("Detach alias")) {
let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect();
if let Some(resolved) = view.vars.resolve(&v.id, &modes) {
set(app, v, mode, value_json(&resolved));
}
if hovered {
detach(app, ui, view, v, coll, mode, (id.with("detach"), alias_btn), tr("Detach alias"));
}
return;
}
// A calculation shows what it resolves to and names its operands. It is set through the
// commands; here, like an alias, it can be swapped for an alias or detached to its colour.
if matches!(val, VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) {
let name = |id: &str| view.vars.get_by_id(id).map_or_else(|| tr("Missing variable").to_string(), |x| x.name.clone());
let formula = val.display_with(&name);
let resp = ui.interact(body, id.with("calc"), if editable { Sense::click() } else { Sense::hover() });
let resp = resp.tip(format!("{formula}\n{}", tr("Calculated from other variables; it follows them")));
let shown = match view.vars.flattened(v, mode, val) {
Some(VariableValue::Color(c)) => Some(c),
_ => None,
};
// The formula is the point of the cell: it keeps the width an alpha readout would take.
read_only_cell(ui, body, shown, formula, false);
if resp.clicked() {
crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
}
if hovered {
detach(app, ui, view, v, coll, mode, (id.with("detach"), alias_btn), tr("Detach calculation"));
}
return;
}
@@ -874,7 +890,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
set(app, v, mode, json!(!b));
}
}
VariableValue::Alias(_) => {}
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => {}
}
if hovered && widgets::icon_button_at(ui, id.with("mkalias"), alias_btn, Icon::Variable, tr("Create alias")) {
crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
@@ -890,20 +906,33 @@ fn alpha_text(ui: &Ui, c: &figcraft_color::Color, body: Rect) {
/// A value that only shows (a library's): its swatch and hex for colours, else its text.
fn read_only_value(ui: &Ui, val: &VariableValue, body: Rect) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
let mut x = body.min.x + 8.0;
if let VariableValue::Color(c) = val {
let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0));
widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c));
x = sw.max.x + 8.0;
alpha_text(ui, c, body);
}
let swatch = match val {
VariableValue::Color(c) => Some(*c),
_ => None,
};
let text = match val {
VariableValue::Boolean(b) => (if *b { tr("True") } else { tr("False") }).to_string(),
other => other.display(),
};
let text_r = Rect::from_min_max(pos2(x, body.min.y), pos2(body.max.x - 44.0, body.max.y));
read_only_cell(ui, body, swatch, text, true);
}
/// A cell that only shows: the colour's swatch (and its alpha at the right end when `alpha`), then
/// `text`.
fn read_only_cell(ui: &Ui, body: Rect, swatch: Option<figcraft_color::Color>, text: String, alpha: bool) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
let mut x = body.min.x + 8.0;
if let Some(c) = swatch {
let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0));
widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(c));
x = sw.max.x + 8.0;
if alpha {
alpha_text(ui, &c, body);
}
}
let right = if alpha && swatch.is_some() { 44.0 } else { 4.0 };
let text_r = Rect::from_min_max(pos2(x, body.min.y), pos2(body.max.x - right, body.max.y));
ui.painter().with_clip_rect(text_r).text(text_r.left_center(), Align2::LEFT_CENTER, text, regular(11.0), t.text);
}
+2 -1
View File
@@ -560,7 +560,8 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option<Var
.map(VariableValue::Color)
}
VariableValue::String(s) => widgets::text_field(ui, id, s, w, tr("Text")).filter(|n| n != s).map(VariableValue::String),
VariableValue::Alias(_) => None,
// References are picked, not typed; calculations can't be expression values.
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None,
}
}
+3 -2
View File
@@ -220,14 +220,15 @@ pub(crate) fn action_summary(doc: &Document, a: &Action) -> String {
}
}
/// A variable value as a set variable action stores it.
/// A plain variable value as commands take it (a set variable action, `variable.set`). References
/// and calculations give null: they have no plain form, so a caller resolves them first.
pub(crate) fn value_json(v: &VariableValue) -> Value {
match v {
VariableValue::Color(c) => json!(c.to_css_hex()),
VariableValue::Number(n) => json!(n),
VariableValue::Boolean(b) => json!(b),
VariableValue::String(s) => json!(s),
VariableValue::Alias(_) => Value::Null,
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => Value::Null,
}
}
+4 -3
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -63,9 +63,9 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command |
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only |
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only |
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show |
| Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M3, #1280: a colour variable can be calculated from others, per mode: `{"alpha": {"variable", "opacity"}}` is that variable's colour with its alpha replaced (replace, not multiply, so re-editing the source's alpha never compounds), `{"mix": {"from", "to", "t"}}` blends two operands (`{"variable"}` or `{"literal"}`) per channel over the stored values in the document's colour profile, alpha included (converting the profile keeps a mix's ends exact and moves its colours in between slightly). Calculations resolve lazily like aliases, so bound layers, mode switches, instances and undo follow their sources; they compose (a calculation of a calculation), a loop is refused when it is set, and one resolve consults at most 32 variables. Files keep them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes (other collections in their default modes, so a CSS calculation doesn't follow another collection's `data-` mode); deleting a source leaves its readers on the colour they showed; library import and swap re-point their operands. The panel shows them read-only (swatch and formula) and can swap one for an alias or detach it to its colour. Tests: `engine/src/variables_colour_tests.rs`, `doc/src/tests.rs`. Left: editing calculations in the panel; files using them need this build to open. Numbers unchanged |
| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |
| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. |
| 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. |
+6 -3
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -111,7 +111,8 @@ source paint coordinates.
follows from those as derived data: each layer's size, place and outline and the colours its
variables give in the instance's mode; an instance scaled as a whole says its scale factor);
variable collections with their modes and variables (colour, number, string and boolean values
per mode, aliases, descriptions, scopes) as nodes on a hidden page, the variables bound to
per mode, aliases, colour calculations as the colour they show in each mode, descriptions,
scopes) as nodes on a hidden page, the variables bound to
paints, gradient stops, effect and layout grid fields and to layer and text fields (size, gaps,
padding, radii, stroke weights, opacity, visibility, characters, font and paragraph settings), and
the modes frames and pages set; shared paint, text, effect and grid styles as nodes on that page
@@ -123,7 +124,8 @@ source paint coordinates.
controller buttons; navigate, overlays with their position, outside-click setting and
background, swap, change to, scroll to, back, close, open link; transitions and easing, custom
curves included), scrolling and fixed layers, flow starting points.
- **Left out, with a warning each:** variable bindings the format has no field for (component
- **Left out, with a warning each:** colour calculations that resolve to nothing (an operand
missing, or more than 32 variables to read), variable bindings the format has no field for (component
properties, arc fields, font weight: written as the values they give), text styles on part of a
text (written as values), variables' publishing, code syntax and inheritance settings, extended
collections (written as collections of their own), comments, Dev Mode annotations, statuses,
@@ -187,6 +189,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M3, #1280: `.fig` export writes a colour calculation (a variable's `{alpha}` or `{mix}` value) as the colour it shows in each mode of its collection, other collections in their default modes; one that resolves to nothing (an operand missing, or more than 32 variables to read) is left out with a warning. Tests: `crates/format/src/fig/assets_tests.rs`. Numbers unchanged. |
| 2026-10-30 | minor | Fonts: the missing-font row adds that a family is found under the other names its fonts give it (legacy per-weight names and typographic ones) and with other case and spacing, so a `.fig`, REST or pasted file no longer reports an installed family missing (#1292). Numbers unchanged |
| 2026-10-30 | minor | M8, #574: `.fig` import budgets four decoded values per compressed canvas byte instead of one; the 20M floor and the 128M ceiling (32M on 32-bit targets) are unchanged. Dense legitimate files went past one value per compressed byte (further since every scalar field counts) and were refused whole as "not a valid FigCraft file: .fig file: message: too many values". Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; a 29 KB canvas that unpacks to 30 million values is still refused. Files that now open instead of being refused take the memory they need: the 49.7 MB canvas's test peaks at about 9 GB. Tests: `crates/format/src/fig/value_budget_tests.rs` (the heavy ones with `--ignored`) and `large_files_get_a_larger_value_budget` in `crates/format/src/fig/tests.rs`. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
+7 -4
View File
@@ -237,7 +237,8 @@ Or in a project's `.mcp.json`:
- **Colour profile**: colour values are in the file's profile, sRGB (default) or Display P3
(`inspect_document` reports `colorProfile`). `file.colorProfile {profile: srgb|displayP3,
convert?}` changes it: `convert` (default true) converts every colour value so colours keep their
appearance; `false` keeps the values and reinterprets them. Images are converted from their own colour
appearance (a colour calculation's mix keeps its ends exact; its colours in between move
slightly); `false` keeps the values and reinterprets them. Images are converted from their own colour
profile into the file's space: an embedded ICC profile (PNG, JPG, WebP, TIFF; RGB or grey), a PNG's
`sRGB` / `gAMA` / `cHRM` chunks, else sRGB (also for CMYK or damaged profiles); `image.place` reports
the profile's name as `imageProfile` (`null`: none usable, taken as sRGB). PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus
@@ -349,7 +350,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}` or a colour calculation (`{alpha}`, `{mix}`), `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
@@ -1161,8 +1162,10 @@ frame back to Auto, inheriting its parent's mode).
The same JSON carries variables between FigCraft files: `variable.export {collections?, format?,
path?}` writes this file's collections (default: all but library copies) as a local variables
response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`, numbers
response (`format: "json"`, the default; colour calculations as the colour they show in each mode,
other collections in their default modes) or as CSS custom properties (`format: "css"`: the default
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`, colour
calculations as the colour they show in that mode (other collections in their default modes), numbers
in px unless the layers bound to them, their scopes or the variables aliasing them make them an
opacity or a font weight, so `border-radius: var(--radius)` reads `12px`) or as
Dart constants for Flutter (`format: "dart"`: an `abstract final class` per collection and mode, the