mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 12:19:36 -06:00
Fixes from agent reports: hex colours everywhere, camelCase in/out, unique variable ids, strict batch, ui.key modifiers, export rounding, Fit images, inner shadows on all edges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b36fd771ab
commit
b26b449fe3
@@ -250,7 +250,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
|
||||
let cmds: Vec<(String, Value)> = match get(p, "commands") {
|
||||
Some(Value::Array(a)) => {
|
||||
a.iter().filter_map(|c| Some((c.get("command")?.as_str()?.to_string(), c.get("params").cloned().unwrap_or(json!({}))))).collect()
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in a.iter().enumerate() {
|
||||
let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
|
||||
out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({}))));
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => return Err(bad("command.batch", "`commands` must be an array")),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,32 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
|
||||
(widthAndHeight|height|none|truncate), textDecoration, textCase, paragraphSpacing, listStyle, pointCount, innerRadius, arcStart, arcSweep, \
|
||||
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
pub fn snake(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(m) => Value::Object(
|
||||
m.iter()
|
||||
.map(|(k, v)| {
|
||||
let mut out = String::new();
|
||||
for c in k.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
// Paint/effect type tags stay as they are.
|
||||
let key = if matches!(k.as_str(), "type" | "kind") { k.clone() } else { out };
|
||||
(key, if matches!(k.as_str(), "type" | "kind") { v.clone() } else { snake(v) })
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(a) => Value::Array(a.iter().map(snake).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
@@ -209,7 +235,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"layout" | "layoutMode" => {
|
||||
@@ -229,7 +255,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
};
|
||||
ensure_layout(n).mode = mode;
|
||||
}
|
||||
Value::Object(_) => n.layout = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("layout: {e}")))?),
|
||||
Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?),
|
||||
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
|
||||
}
|
||||
}
|
||||
@@ -293,9 +319,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?;
|
||||
}
|
||||
}
|
||||
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("grids: {e}")))?,
|
||||
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?,
|
||||
"exports" | "exportSettings" => {
|
||||
doc.get_mut(id)?.exports = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("exports: {e}")))?
|
||||
doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))?
|
||||
}
|
||||
"interactions" => {
|
||||
doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let id = format!("VariableCollectionId:{}", doc.variables.collections.len() + 1);
|
||||
let id = unique_id("VariableCollectionId", doc.variables.collections.iter().map(|c| c.id.as_str()));
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
@@ -396,7 +396,7 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for m in &coll.modes {
|
||||
values.entry(m.id.clone()).or_insert_with(|| default.clone());
|
||||
}
|
||||
let id = format!("VariableID:{}", doc.variables.variables.len() + 1);
|
||||
let id = unique_id("VariableID", doc.variables.variables.iter().map(|v| v.id.as_str()));
|
||||
doc.variables.variables.push(Variable {
|
||||
id: id.clone(),
|
||||
name,
|
||||
@@ -455,3 +455,9 @@ pub fn apply_bindings(doc: &mut Document) {
|
||||
let _ = super::props::apply("variables", doc, id, &sets);
|
||||
}
|
||||
}
|
||||
|
||||
/// `prefix:N` with N one more than the largest existing.
|
||||
fn unique_id<'a>(prefix: &str, existing: impl Iterator<Item = &'a str>) -> String {
|
||||
let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::<u64>().ok()).max().unwrap_or(0);
|
||||
format!("{prefix}:{}", max + 1)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), serde_json::to_value(&n.effects).unwrap_or(Value::Null));
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert(
|
||||
@@ -69,7 +69,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
}
|
||||
}
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), serde_json::to_value(l).unwrap_or(Value::Null));
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
@@ -88,7 +88,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), serde_json::to_value(&n.grids).unwrap_or(Value::Null));
|
||||
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
@@ -217,3 +217,30 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
||||
"history": {"undo": ds.history.undo.iter().rev().take(20).map(|h| h.label.clone()).collect::<Vec<_>>(), "redo": ds.history.redo.len()},
|
||||
})
|
||||
}
|
||||
|
||||
/// snake_case object keys → camelCase (inspect output matches the keys `node.set` accepts).
|
||||
pub fn camel(v: Value) -> Value {
|
||||
match v {
|
||||
Value::Object(m) => Value::Object(
|
||||
m.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let mut out = String::new();
|
||||
let mut up = false;
|
||||
for c in k.chars() {
|
||||
if c == '_' {
|
||||
up = true;
|
||||
} else if up {
|
||||
out.extend(c.to_uppercase());
|
||||
up = false;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
(out, camel(v))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(a) => Value::Array(a.into_iter().map(camel).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user