mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
**A styles and variables behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (what a style holds, creating from a selection, applying, editing, detaching, deleting a used style, organising by folders and order, descriptions; variable types, modes and the default mode, aliases, scopes, mode inheritance, bindings by field and type, deleting a bound variable, code syntax, number variables in pixels) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins the behaviour.
**Fixes (10 of the suite's 18 tests failed on the old code, counting the missing settings below)**
- **Editing a styled property unlinks that style**: a fill, stroke, effect or layout grid list edited away from its style (through `node.set`, a paint row, an added fill, or a variable bound to it) drops the link, as text chars already did. Before, the layer kept a stale link and the next style edit silently overwrote the user's edit. Moving or renaming a layer keeps its links.
- **Applying a style replaces variables bound to what it sets** (the fill list for a paint style, the typography for a text style); other bindings stay. Before, the variable won again on the next edit.
- **Text styles go on text layers only**: applying one to a mixed selection leaves the other layers alone (they used to get a stray text style link); a selection without text is refused.
- **Deleting a bound variable unbinds the layers using it**: they keep the value they show; variables aliasing it take its values per mode (docs silent on aliases). Before, the binding dangled. `variable.deleteCollection` now does the same for aliases from other collections into it.
- **Bindings are type-checked**: a variable of a type the field doesn't take (a colour on `width`, a number on `visible`) is refused; scopes still only filter pickers. Number variables can fill text content (shown as text).
- **Aliases are checked when a variable is created** (`variable.create {value: {alias}}` took any name, of any type).
- **Style names** are trimmed per `/` folder part; empty names are refused.
**Missing documented settings, added**
- **Organising styles**: `style.move {styles, before?, folder?}` reorders and moves into a folder (renaming the style to match), `style.duplicate` puts "<name> copy" right below the original, `style.renameFolder` renames a folder's styles. The page's **Local styles** list (nothing selected) is now grouped by type and folder with swatches and text sizes: click opens the style editor, right-click offers Edit style / Duplicate / Delete, a row dragged by its grip moves (into the folder it lands in), right-click on a folder renames it.
- **Modes**: Duplicate mode (`variable.addMode {from}`), Set as default and Move column left/right (`variable.moveMode {collection, mode, to}`); layers on Auto follow a new default, layers set to a mode keep theirs.
- **Code syntax** per platform (Web, iOS, Android): `variable.update {codeSyntax}`, three fields in Edit variable, carried by the variables JSON (import and export); generated CSS and the CSS variables export use the Web name (`var(--x)` or `--x`).
- **Number variable scopes**: font weight, paragraph spacing, paragraph indent and text content (and `fontWeight`, `paragraphSpacing`, `paragraphIndent` bind with their scopes).
New strings go through `tr(...)` with `en.toml` / `es.toml` entries, new commands have Spanish labels; the new rows, fields and menus have accessible names. Each edit stays one undo step.
141 lines
6.6 KiB
Rust
141 lines
6.6 KiB
Rust
//! Variable mode lookups in prototype expressions: a variable's value in a given mode of its
|
|
//! collection (`n-name[Dark]`) and the mode a collection is in (`mode(Theme)`), typed, checked,
|
|
//! evaluated, written back and stored as JSON.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use crate::proto::*;
|
|
use crate::proto_expr_tests::vars;
|
|
use crate::{Collection, Mode, Variable, VariableType, VariableValue, Variables};
|
|
|
|
/// The test variables plus a collection "Color scheme" (modes "High contrast" and "a]b") with a
|
|
/// number variable `k` ("k-name").
|
|
fn more_vars() -> Variables {
|
|
let mut v = vars();
|
|
v.collections.push(Collection {
|
|
id: "cs".into(),
|
|
name: "Color scheme".into(),
|
|
modes: vec![Mode { id: "hc".into(), name: "High contrast".into() }, Mode { id: "odd".into(), name: "a]b".into() }],
|
|
extends: None,
|
|
});
|
|
v.variables.push(Variable {
|
|
id: "k".into(),
|
|
name: "k-name".into(),
|
|
collection: "cs".into(),
|
|
kind: VariableType::Number,
|
|
values: BTreeMap::from([("hc".to_string(), VariableValue::Number(1.0)), ("odd".to_string(), VariableValue::Number(2.0))]),
|
|
description: String::new(),
|
|
scopes: None,
|
|
hidden_from_publishing: false,
|
|
extends: None,
|
|
overrides: Default::default(),
|
|
code_syntax: Default::default(),
|
|
});
|
|
v
|
|
}
|
|
|
|
fn parse(text: &str) -> Expr {
|
|
Expr::parse(text, &more_vars()).unwrap_or_else(|e| panic!("{text}: {e:?}"))
|
|
}
|
|
|
|
fn error(text: &str) -> String {
|
|
match Expr::parse(text, &more_vars()) {
|
|
Ok(e) => panic!("{text} parsed as {e:?}"),
|
|
Err(e) => e.message,
|
|
}
|
|
}
|
|
|
|
fn in_mode(var: &str, mode: &str) -> Expr {
|
|
Expr::InMode { variable: var.into(), mode: mode.into() }
|
|
}
|
|
|
|
fn mode_of(c: &str) -> Expr {
|
|
Expr::Mode { collection: c.into() }
|
|
}
|
|
|
|
#[test]
|
|
fn reads_mode_lookups_and_writes_them_back() {
|
|
use ExprOp::*;
|
|
assert_eq!(parse("n-name[Dark]"), in_mode("n", "dark"));
|
|
assert_eq!(parse("n-name [ dark ] + 1"), Expr::op(Add, vec![in_mode("n", "dark"), Expr::Value(VariableValue::Number(1.0))]), "ids, spaces");
|
|
assert_eq!(parse("{k-name}[High contrast]"), in_mode("k", "hc"));
|
|
assert_eq!(parse(r#"k-name["a]b"]"#), in_mode("k", "odd"), "quoted names may hold brackets");
|
|
assert_eq!(parse("mode(Theme)"), mode_of("c"));
|
|
assert_eq!(
|
|
parse("MODE ( 'Color scheme' ) == \"High contrast\""),
|
|
Expr::op(Equals, vec![mode_of("cs"), Expr::Value(VariableValue::String("High contrast".into()))])
|
|
);
|
|
assert_eq!(parse("mode({Color scheme})"), mode_of("cs"));
|
|
assert_eq!(parse("mode(c) != \"Dark\" and n-name[light] < n-name"), parse("(mode(Theme) != 'Dark') and (n-name[Light] < n)"));
|
|
let vars = more_vars();
|
|
for text in [
|
|
"n-name[Dark]",
|
|
"k-name[High contrast]",
|
|
"k-name[\"a]b\"]",
|
|
"mode(Theme) == \"Dark\"",
|
|
"mode(Color scheme)",
|
|
"(mode(Theme) == \"Light\") or (k-name[High contrast] > 0)",
|
|
] {
|
|
let e = parse(text);
|
|
assert_eq!(e.display(&vars), text, "{e:?}");
|
|
assert_eq!(Expr::parse(&e.display(&vars), &vars), Ok(e.clone()), "{text} reads back");
|
|
// Stored as JSON and read back.
|
|
let json = serde_json::to_value(&e).unwrap();
|
|
assert_eq!(serde_json::from_value::<Expr>(json).unwrap(), e);
|
|
}
|
|
assert_eq!(serde_json::to_value(in_mode("n", "dark")).unwrap(), serde_json::json!({"inMode": {"variable": "n", "mode": "dark"}}));
|
|
assert_eq!(serde_json::to_value(mode_of("c")).unwrap(), serde_json::json!({"mode": {"collection": "c"}}));
|
|
}
|
|
|
|
#[test]
|
|
fn mode_lookup_errors_say_what_is_wrong() {
|
|
assert_eq!(error("n-name[Sepia]"), "Theme has no mode `Sepia`");
|
|
assert_eq!(error("n-name[High contrast]"), "Theme has no mode `High contrast`", "another collection's mode");
|
|
assert_eq!(error("n-name[Dark"), "Add a closing ]");
|
|
assert_eq!(error("n-name[]"), "Name a mode between the brackets");
|
|
assert_eq!(error("mode(Nope)"), "No variable collection `Nope`");
|
|
assert_eq!(error("mode(Theme"), "Add a closing )");
|
|
assert_eq!(error("mode()"), "Name a collection between the brackets");
|
|
assert_eq!(error("mode(\"Theme)"), "Close the text with \"");
|
|
// `mode` alone isn't a function call (nor a variable here).
|
|
assert_eq!(error("mode"), "No variable `mode`");
|
|
// Type checks: a lookup has its variable's type, a mode is text.
|
|
let vars = more_vars();
|
|
assert_eq!(parse("n-name[Dark] + 1").type_of(&vars), Ok(VariableType::Number));
|
|
assert_eq!(parse("mode(Theme)").type_of(&vars), Ok(VariableType::String));
|
|
assert_eq!(parse("mode(Theme) == 1").check_condition(&vars), Err("Can't compare text with a number".into()));
|
|
assert_eq!(parse("mode(Theme) == 'Dark'").check_condition(&vars), Ok(()));
|
|
// Stored expressions whose collection, variable or mode is gone.
|
|
assert_eq!(mode_of("gone").type_of(&vars), Err("No variable collection `gone`".into()));
|
|
assert_eq!(in_mode("gone", "dark").type_of(&vars), Err("No variable `gone`".into()));
|
|
assert_eq!(in_mode("n", "gone").type_of(&vars), Err("Theme has no mode `gone`".into()));
|
|
assert_eq!(in_mode("n", "gone").display(&vars), "n-name[\"(missing mode)\"]");
|
|
assert_eq!(mode_of("gone").display(&vars), "mode(\"(missing collection)\")");
|
|
}
|
|
|
|
#[test]
|
|
fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
|
|
let vars = more_vars();
|
|
let light = BTreeMap::new();
|
|
let dark = BTreeMap::from([("c".to_string(), "dark".to_string())]);
|
|
let ev = |text: &str, modes: &BTreeMap<String, String>| parse(text).eval(&vars, modes);
|
|
// A lookup reads its mode whichever mode is chosen; plain references follow the chosen one.
|
|
assert_eq!(ev("n-name[Dark]", &light), Some(VariableValue::Number(7.0)));
|
|
assert_eq!(ev("n-name[Light]", &dark), Some(VariableValue::Number(3.0)));
|
|
assert_eq!(ev("n-name", &dark), Some(VariableValue::Number(7.0)));
|
|
// Aliases resolve in the looked-up mode.
|
|
assert_eq!(ev("alias-name[Dark]", &light), Some(VariableValue::Number(7.0)));
|
|
// The current mode by name: the default when none is chosen (or the chosen one is gone).
|
|
assert_eq!(ev("mode(Theme)", &light), Some(VariableValue::String("Light".into())));
|
|
assert_eq!(ev("mode(Theme)", &dark), Some(VariableValue::String("Dark".into())));
|
|
assert_eq!(
|
|
parse("mode(Theme)").eval(&vars, &BTreeMap::from([("c".to_string(), "gone".to_string())])),
|
|
Some(VariableValue::String("Light".into()))
|
|
);
|
|
assert!(parse("mode(Theme) == \"Dark\" and k-name[\"a]b\"] == 2").holds(&vars, &dark));
|
|
// Missing parts evaluate to nothing.
|
|
assert_eq!(in_mode("n", "gone").eval(&vars, &light), None);
|
|
assert_eq!(mode_of("gone").eval(&vars, &light), None);
|
|
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
|
|
}
|