mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 09:33:58 +00:00
## What
**Layers panel from the keyboard** (two of the three open items of area 3, plus long-list QA)
- **F2 renames** the selected layer (a second key for `object.rename`, next to Cmd/Ctrl+R) from the canvas, after a click on a row, or from a layer row that has the keyboard (F6 mode, where the canvas shortcuts are off, so the layers branch of `a11y::keys` runs it). When the rename ends the row gets the keyboard back (`a11y::refocus_row`); Escape in the field cancels and stays in the panel instead of jumping to the canvas.
- **Shift+F10 opens the context menu** (`view.contextMenu`, a new command that only asks the UI): at the focused layer row when the layers panel has the keyboard (F6 or a clicked row), else on the canvas just below the selection (the middle of the canvas with nothing selected). The menu takes the keyboard like the other menus opened from the keyboard (`a11y::popup`), and Escape gives it back to the row or the canvas. The Menu key can't be supported: egui 0.36 has no key for it.
- **Keyboard box selection** (`select.area`, F8): for keyboard-only users, a selection box appears around the selection (or in the middle of the view); the arrows move it, Shift+arrows move its far corner (10 screen pixels a press), Enter selects what it covers exactly like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter takes nested layers), Escape or a click drops it. A toast and the canvas's screen-reader description say the box's size and the keys. With a box (`{x, y, width, height, add?, deep?}`) the command selects directly, for agents. The marquee's rules (top-level layers, frames only when covered, the children of the frame the box starts in, the focus view, Shift toggling) moved into `Session::marquee_within` / `marquee_selection`, shared by the pointer marquee and the command.
- **Long lists (10k layers)**: a test that a layer near the bottom of a 10k-layer list is revealed (parents expanded, list scrolled) and that F6 lands the keyboard on its row and the arrows walk on from it; the UI bench (`perf_ui_frames`) gained rows for expanding every layer, arrow keys down the expanded list, revealing a layer at the bottom and collapsing everything. Release, 10k layers: first frame with every layer expanded 1.22 ms, an arrow key 1.04 ms, reveal 0.30 ms, collapse all 0.37 ms, idle 0.17 ms.
**Stroke styles** (area 6)
- What the public documentation describes for strokes was mostly already in: colour styles apply to strokes from the stroke section's style picker (`style.apply {target: "stroke"}`, linked, following `style.update`, one undo step, `style.detach {which: "stroke"}` keeping the colour), and the stroke weight binds to number variables. A test now covers that path.
- Missing piece, added: **each side's stroke weight binds to a number variable on its own** (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`): `node.set` keys (setting or binding one side turns on individual strokes with the other sides at the weight), the stroke-weight variable scope, the stroke override group for instances, and REST `boundVariables` import. The Design panel's per-side fields are bindable, two to a row like the corner radii so a variable's name fits.
New strings go through `tr(...)` with `en.toml` / `es.toml` entries, and the two new commands have Spanish labels. Shortcuts are only in `keymap.rs`. Nothing redraws while idle (the box draws only when a key moves it). Selecting from the box leaves no undo step; each stroke edit is one.
## Verified
- **Desktop app** over the control channel (`target/debug/figcraft.exe` from this worktree's target dir, `--control <free port>`, separate `APPDATA`, keys sent as real `ui.input` key events, stopped by PID): F2 on a selected layer opens its name field in the layers panel (screenshot), typing and Enter rename it; F6 to the layers panel, Shift+F10 opens the menu at the Avatar row with Copy focused (screenshot), Escape gives the keyboard back to the row, F2 + typing + Enter renames it and the row keeps the keyboard ("Profile"); Shift+F10 on the canvas opens the menu for the selected frame with Copy focused (screenshot); F8 shows a box around the selected frame, Shift+arrows stretch it over a second layer (screenshot with the hint toast), Enter selects both; a rectangle with the colour style "Brand/Accent" on its stroke and its top weight bound to a number variable (screenshot: the style chip in the stroke section, the red stroke thicker at the top, the per-side field showing the variable).
- **MCP**: `figcraft-cli mcp` (headless): created a paint style, `style.apply {target: "stroke"}`, `object.rename`, bound `strokeLeftWeight` to a variable and ran `select.area` over the layer; `inspect_document` shows the name "Outlined card", `styles.stroke: "S:1"` with the style's colour on the strokes, `strokeSides [2, 2, 2, 6]`, `boundVariables {strokeLeftWeight}`, and one undo step per command.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: release UI bench at 10k layers (numbers above).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.
## Tests
- `ui-egui` `layers_keyboard_tests` (3): F2 from the canvas and from a focused row (the row gets the keyboard back, Escape cancels without leaving the panel); Shift+F10 at a focused row, on the canvas below the selection and after a clicked row, the menu taking and giving back the keyboard; F8: the box around the selection, the screen-reader description, Shift+arrows sizing, Enter selecting, arrows moving it without nudging layers, Escape, the middle of the view with nothing selected, a click dropping it.
- `ui-egui` `perf_tests::a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row`, plus the new bench rows.
- `engine` `select_area_tests` (2): marquee rules through the command (negative sizes, frames, starting inside a frame, deep, add toggling, empty spots), the UI request with no box, bad boxes refused (the junk sweep covers both new commands too).
- `engine` `stroke_style_tests` (2): a colour style on strokes (link, undo, style edits, detach); stroke weight and per-side weights bound to variables following the frame's mode, `node.set` per-side keys, scopes and override groups.
## Not in this PR
- The Menu (application) key: egui 0.36 doesn't deliver it.
- Enter doesn't rename: on the canvas and a focused row it already selects children / activates the row.
- The keyboard box doesn't pan the view when it is moved off screen (zoom and pan keys still work while it shows).
- Shift+F10 does nothing while a toolbar or properties control has the keyboard.
- Progress docs: area 3 95%/78% → 98%/82%, 2–4 h → 1–2 h; area 6 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; percentages unchanged at this rounding; full parity ~323–621 h → ~322–618 h (target-app-parity, ROADMAP); ui-parity rows (context menus, keyboard box selection, layers from the keyboard); `docs/mcp.md` documents `object.rename` without a name, `view.contextMenu`, `select.area` and stroke styles and variables.
388 lines
17 KiB
Rust
388 lines
17 KiB
Rust
//! Editing variables, modes and collections (the Variables editor): rename, describe, scope,
|
|
//! duplicate and delete; extended collections (overrides, keeping them in step with what they
|
|
//! extend); alias checks; and which scope a bindable property belongs to.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use figcraft_doc::*;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::lists::BoundList;
|
|
use super::{CommandSpec, ok};
|
|
use crate::params::*;
|
|
use crate::{Result, Session, bad, spec};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!(
|
|
"variable.update",
|
|
"Edit variable",
|
|
"{variable, name?, description?, scopes? ([scope ids]; null or [\"ALL_SCOPES\"] = every supported property, [] = hidden from pickers), hiddenFromPublishing? (left out when the file is published as a library)}",
|
|
true,
|
|
update
|
|
),
|
|
spec!("variable.duplicate", "Duplicate variable", "{variable} → {id}", true, duplicate),
|
|
spec!("variable.renameMode", "Rename mode", "{collection, mode, name}", true, rename_mode),
|
|
spec!("variable.removeMode", "Delete mode", "{collection, mode} (a collection keeps at least one mode)", true, remove_mode),
|
|
spec!("variable.renameCollection", "Rename collection", "{collection, name}", true, rename_collection),
|
|
spec!(
|
|
"variable.deleteCollection",
|
|
"Delete collection",
|
|
"{collection} (with its variables; bound layers keep their values)",
|
|
true,
|
|
delete_collection
|
|
),
|
|
spec!(
|
|
"variable.resetOverride",
|
|
"Reset variable override",
|
|
"{variable (a variable of an extended collection inherited from the collection it extends), mode? (id or name; every overridden mode when omitted)} the value follows the inherited one again",
|
|
true,
|
|
reset_override
|
|
),
|
|
]
|
|
}
|
|
|
|
/// The first of `base`, `base 2`, `base 3`… that no collection is named.
|
|
pub(crate) fn free_collection_name(doc: &Document, base: &str) -> String {
|
|
(1..1000)
|
|
.map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") })
|
|
.find(|n| !doc.variables.collections.iter().any(|c| c.name == *n))
|
|
.unwrap_or_else(|| base.to_string())
|
|
}
|
|
|
|
/// The error for editing what an extended collection inherits.
|
|
fn inherited(cmd: &str, what: &str) -> crate::EngineError {
|
|
bad(cmd, format!("{what} comes from the collection it extends (edit it there)"))
|
|
}
|
|
|
|
/// Refuse mode edits of extended collections (they have the modes of the collection they extend).
|
|
pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> {
|
|
match doc.variables.collection(collection) {
|
|
Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")),
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// A variable name with each `/`-separated group part trimmed; None when nothing is left.
|
|
fn clean_name(name: &str) -> Option<String> {
|
|
let parts: Vec<&str> = name.split('/').map(str::trim).filter(|s| !s.is_empty()).collect();
|
|
let out = parts.join("/");
|
|
(!out.is_empty()).then(|| out.chars().take(500).collect())
|
|
}
|
|
|
|
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.update";
|
|
let v = req_string(cmd, p, "variable")?;
|
|
let doc = s.doc_mut()?;
|
|
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
|
let name = match string(p, "name") {
|
|
Some(_) if var.extends.is_some() => return Err(inherited(cmd, "an inherited variable's name")),
|
|
Some(n) => {
|
|
let n = clean_name(&n).ok_or_else(|| bad(cmd, "name can't be empty"))?;
|
|
if doc.variables.variables.iter().any(|x| x.id != var.id && x.collection == var.collection && x.name == n) {
|
|
return Err(bad(cmd, format!("a variable named `{n}` already exists in this collection")));
|
|
}
|
|
Some(n)
|
|
}
|
|
None => None,
|
|
};
|
|
let scopes = match get(p, "scopes") {
|
|
None => None,
|
|
Some(Value::Null) => Some(None),
|
|
Some(Value::Array(a)) => {
|
|
let mut ids = Vec::with_capacity(a.len());
|
|
for x in a {
|
|
let id = x.as_str().ok_or_else(|| bad(cmd, "scopes: [scope ids]"))?;
|
|
if !scope_known(var.kind, id) {
|
|
return Err(bad(cmd, format!("`{id}` isn't a scope for {} variables", var.kind.id())));
|
|
}
|
|
ids.push(id);
|
|
}
|
|
// Every scope listed is the same as no limit.
|
|
Some(normalize_scopes(var.kind, ids))
|
|
}
|
|
Some(_) => return Err(bad(cmd, "scopes: [scope ids] or null")),
|
|
};
|
|
let slot = doc.variables.variables.iter_mut().find(|x| x.id == var.id).ok_or_else(|| bad(cmd, "no such variable"))?;
|
|
if let Some(n) = name {
|
|
slot.name = n;
|
|
}
|
|
if let Some(d) = string(p, "description") {
|
|
slot.description = d.chars().take(10_000).collect();
|
|
}
|
|
if let Some(sc) = scopes {
|
|
slot.scopes = sc;
|
|
}
|
|
if let Some(h) = boolean(p, "hiddenFromPublishing") {
|
|
slot.hidden_from_publishing = h;
|
|
}
|
|
ok()
|
|
}
|
|
|
|
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.duplicate";
|
|
let v = req_string(cmd, p, "variable")?;
|
|
let doc = s.doc_mut()?;
|
|
let pos = doc.variables.variables.iter().position(|x| x.id == v || x.name == v).ok_or_else(|| bad(cmd, "no such variable"))?;
|
|
let mut copy = doc.variables.variables.get(pos).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
|
let taken = |n: &str| doc.variables.variables.iter().any(|x| x.collection == copy.collection && x.name == n);
|
|
let name = (1..1000)
|
|
.map(|k| if k == 1 { format!("{} copy", copy.name) } else { format!("{} copy {k}", copy.name) })
|
|
.find(|n| !taken(n))
|
|
.ok_or_else(|| bad(cmd, "too many copies"))?;
|
|
copy.id = doc.new_variable_id();
|
|
copy.name = name;
|
|
// A copy of an inherited variable is the extended collection's own.
|
|
copy.extends = None;
|
|
copy.overrides.clear();
|
|
let id = copy.id.clone();
|
|
doc.variables.variables.insert(pos + 1, copy);
|
|
Ok(json!({"id": id}))
|
|
}
|
|
|
|
/// The index of collection `c` (id or name).
|
|
fn collection_index(doc: &Document, cmd: &str, c: &str) -> Result<usize> {
|
|
doc.variables.collections.iter().position(|x| x.id == c || x.name == c).ok_or_else(|| bad(cmd, "no such collection"))
|
|
}
|
|
|
|
/// Collection `c`'s index and the id of its mode `m` (id or name).
|
|
fn mode_of(doc: &Document, cmd: &str, p: &Value) -> Result<(usize, ModeId)> {
|
|
let ci = collection_index(doc, cmd, &req_string(cmd, p, "collection")?)?;
|
|
let m = req_string(cmd, p, "mode")?;
|
|
let mode = doc
|
|
.variables
|
|
.collections
|
|
.get(ci)
|
|
.and_then(|c| c.modes.iter().find(|x| x.id == m || x.name == m))
|
|
.map(|x| x.id.clone())
|
|
.ok_or_else(|| bad(cmd, "no such mode"))?;
|
|
check_modes_editable(doc, cmd, doc.variables.collections.get(ci).map_or("", |c| &c.id))?;
|
|
Ok((ci, mode))
|
|
}
|
|
|
|
fn rename_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.renameMode";
|
|
let name = req_string(cmd, p, "name")?.trim().chars().take(200).collect::<String>();
|
|
if name.is_empty() {
|
|
return Err(bad(cmd, "name can't be empty"));
|
|
}
|
|
let doc = s.doc_mut()?;
|
|
let (ci, mode) = mode_of(doc, cmd, p)?;
|
|
if let Some(m) = doc.variables.collections.get_mut(ci).and_then(|c| c.modes.iter_mut().find(|x| x.id == mode)) {
|
|
m.name = name;
|
|
}
|
|
ok()
|
|
}
|
|
|
|
fn remove_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.removeMode";
|
|
let doc = s.doc_mut()?;
|
|
let (ci, mode) = mode_of(doc, cmd, p)?;
|
|
let coll = doc.variables.collections.get_mut(ci).ok_or_else(|| bad(cmd, "no such collection"))?;
|
|
if coll.modes.len() < 2 {
|
|
return Err(bad(cmd, "a collection keeps at least one mode"));
|
|
}
|
|
coll.modes.retain(|m| m.id != mode);
|
|
let cid = coll.id.clone();
|
|
for v in doc.variables.variables.iter_mut().filter(|v| v.collection == cid) {
|
|
v.values.remove(&mode);
|
|
}
|
|
// Layers set to the deleted mode go back to the default.
|
|
let users: Vec<NodeId> = doc.nodes().filter(|n| n.var_modes.get(&cid) == Some(&mode)).map(|n| n.id).collect();
|
|
for id in users {
|
|
doc.get_mut(id)?.var_modes.remove(&cid);
|
|
}
|
|
ok()
|
|
}
|
|
|
|
fn rename_collection(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.renameCollection";
|
|
let name = req_string(cmd, p, "name")?.trim().chars().take(200).collect::<String>();
|
|
if name.is_empty() {
|
|
return Err(bad(cmd, "name can't be empty"));
|
|
}
|
|
let doc = s.doc_mut()?;
|
|
let ci = collection_index(doc, cmd, &req_string(cmd, p, "collection")?)?;
|
|
if doc.variables.collections.iter().enumerate().any(|(i, c)| i != ci && c.name == name) {
|
|
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
|
|
}
|
|
if let Some(c) = doc.variables.collections.get_mut(ci) {
|
|
c.name = name;
|
|
}
|
|
ok()
|
|
}
|
|
|
|
fn delete_collection(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.deleteCollection";
|
|
let doc = s.doc_mut()?;
|
|
let ci = collection_index(doc, cmd, &req_string(cmd, p, "collection")?)?;
|
|
let cid = doc.variables.collections.remove(ci).id;
|
|
let gone: BTreeSet<VariableId> = doc.variables.variables.iter().filter(|v| v.collection == cid).map(|v| v.id.clone()).collect();
|
|
doc.variables.variables.retain(|v| v.collection != cid);
|
|
// Layers keep their current values but no longer follow the deleted variables or modes.
|
|
let users: Vec<NodeId> =
|
|
doc.nodes().filter(|n| n.var_modes.contains_key(&cid) || n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
|
|
for id in users {
|
|
let n = doc.get_mut(id)?;
|
|
n.var_modes.remove(&cid);
|
|
n.bound.retain(|_, v| !gone.contains(v));
|
|
}
|
|
Ok(json!({"deletedVariables": gone.len()}))
|
|
}
|
|
|
|
fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.resetOverride";
|
|
let v = req_string(cmd, p, "variable")?;
|
|
let doc = s.doc_mut()?;
|
|
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
|
let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned();
|
|
let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?;
|
|
let modes: Vec<ModeId> = match string(p, "mode") {
|
|
Some(m) => {
|
|
let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
|
vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()]
|
|
}
|
|
None => var.overrides.iter().cloned().collect(),
|
|
};
|
|
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
|
|
for m in modes {
|
|
slot.overrides.remove(&m);
|
|
if let Some(val) = parent.values.get(&m) {
|
|
slot.values.insert(m, val.clone());
|
|
}
|
|
}
|
|
}
|
|
ok()
|
|
}
|
|
|
|
/// Keep extended collections in step with the collections they extend (after extending one or
|
|
/// updating library copies): the same modes, one inheriting variable per variable of the parent
|
|
/// (named like it, with its values in every mode not overridden) and values in every mode for
|
|
/// their own variables. When a parent collection or variable is gone, what extended it becomes a
|
|
/// plain collection or variable, keeping its values.
|
|
pub fn sync_extensions(doc: &mut Document) {
|
|
let exts: Vec<(CollectionId, Option<Collection>)> = doc
|
|
.variables
|
|
.collections
|
|
.iter()
|
|
.filter_map(|c| Some((c.id.clone(), c.extends.as_ref().map(|p| doc.variables.collections.iter().find(|x| x.id == *p).cloned())?)))
|
|
.collect();
|
|
for (cid, parent) in exts {
|
|
let vars = &mut doc.variables.variables;
|
|
// shortcut: an extension of an extension isn't followed (extending is offered for library
|
|
// collections only), upgrade if extended collections get published and extended again.
|
|
let Some(parent) = parent.filter(|p| p.extends.is_none()) else {
|
|
for v in vars.iter_mut().filter(|v| v.collection == cid) {
|
|
v.extends = None;
|
|
v.overrides.clear();
|
|
}
|
|
if let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid) {
|
|
c.extends = None;
|
|
}
|
|
continue;
|
|
};
|
|
let parents: Vec<Variable> = vars.iter().filter(|v| v.collection == parent.id).cloned().collect();
|
|
let modes: Vec<ModeId> = parent.modes.iter().map(|m| m.id.clone()).collect();
|
|
for v in vars.iter_mut().filter(|v| v.collection == cid) {
|
|
match v.extends.as_ref().map(|e| parents.iter().find(|p| p.id == *e)) {
|
|
Some(Some(p)) => {
|
|
if v.kind != p.kind {
|
|
v.kind = p.kind;
|
|
v.overrides.clear();
|
|
}
|
|
v.name.clone_from(&p.name);
|
|
v.overrides.retain(|m| modes.contains(m));
|
|
let own = std::mem::take(&mut v.values);
|
|
v.values =
|
|
p.values.iter().map(|(m, x)| (m.clone(), own.get(m).filter(|_| v.overrides.contains(m)).unwrap_or(x).clone())).collect();
|
|
}
|
|
Some(None) => {
|
|
v.extends = None;
|
|
v.overrides.clear();
|
|
}
|
|
None => {}
|
|
}
|
|
// Its own variables (and orphans) keep a value in every mode: new modes start with the
|
|
// default mode's.
|
|
if v.extends.is_none() {
|
|
let first = modes.iter().find_map(|m| v.values.get(m)).or_else(|| v.values.values().next()).cloned();
|
|
v.values.retain(|m, _| modes.contains(m));
|
|
if let Some(f) = first {
|
|
for m in &modes {
|
|
v.values.entry(m.clone()).or_insert_with(|| f.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let missing: Vec<Variable> =
|
|
parents.into_iter().filter(|p| !vars.iter().any(|v| v.collection == cid && v.extends.as_ref() == Some(&p.id))).collect();
|
|
if let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid)
|
|
&& c.modes != parent.modes
|
|
{
|
|
c.modes = parent.modes.clone();
|
|
}
|
|
for p in missing {
|
|
let id = doc.new_variable_id();
|
|
let extends = Some(p.id.clone());
|
|
doc.variables.variables.push(Variable { id, collection: cid.clone(), extends, hidden_from_publishing: false, ..p });
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
let t = vars.get(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()];
|
|
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 !seen.insert(cur.clone()) || seen.len() > 10_000 {
|
|
continue;
|
|
}
|
|
for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() {
|
|
if let VariableValue::Alias(a) = v
|
|
&& let Some(next) = vars.get(a)
|
|
{
|
|
stack.push(next.id.clone());
|
|
}
|
|
}
|
|
}
|
|
Ok(t.id.clone())
|
|
}
|
|
|
|
/// The variable scope of bindable property `field` on `node` (None: any variable of the type fits).
|
|
pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static str> {
|
|
Some(match field {
|
|
"width" | "height" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "WIDTH_HEIGHT",
|
|
"gap" | "columnGap" | "rowGap" | "crossGap" | "padding" | "paddingHorizontal" | "paddingVertical" | "paddingTop" | "paddingRight"
|
|
| "paddingBottom" | "paddingLeft" => "GAP",
|
|
"cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "CORNER_RADIUS",
|
|
"opacity" => "OPACITY",
|
|
"strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => "STROKE_FLOAT",
|
|
"fontSize" => "FONT_SIZE",
|
|
"lineHeight" => "LINE_HEIGHT",
|
|
"letterSpacing" => "LETTER_SPACING",
|
|
"characters" => "TEXT_CONTENT",
|
|
"fontFamily" => "FONT_FAMILY",
|
|
"fontStyle" => "FONT_STYLE",
|
|
f => match super::lists::list_binding(f)? {
|
|
(BoundList::Strokes, _, _) => "STROKE_COLOR",
|
|
(BoundList::Effects, _, "color") => "EFFECT_COLOR",
|
|
(BoundList::Effects, _, _) => "EFFECT_FLOAT",
|
|
(BoundList::Fills, _, _) => match doc.node(node).map(|n| &n.kind) {
|
|
Some(NodeKind::Text(_)) => "TEXT_FILL",
|
|
Some(k) if k.is_frame_like() => "FRAME_FILL",
|
|
_ => "SHAPE_FILL",
|
|
},
|
|
// Layout grid fields take any number variable.
|
|
(BoundList::Grids, _, _) => return None,
|
|
},
|
|
})
|
|
}
|