mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
node.set: apply auto layout keys to the frames and name the rest; resolve fontStyle like text does
- Several ids: auto layout keys go to the frame-like layers, the others get the remaining keys and are named in `warnings`. Refused only when no target can have auto layout. - fontStyle is never refused: it draws in the nearest face, as a text layer resolves it. `warnings` says so when that face's weight or slant differs from what the name asks for. - set_properties passes `warnings` on to the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c8ba7b4a4b
commit
8cccc48516
@@ -1085,9 +1085,34 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
|
||||
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
|
||||
// the first refuses them (`props::layout_mut`).
|
||||
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
|
||||
let mut without_layout = props.clone();
|
||||
let mut skipped = BTreeSet::new();
|
||||
if !layout_keys.is_empty() {
|
||||
let doc = &s.ds()?.doc;
|
||||
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
|
||||
if ids.iter().any(frame_like) {
|
||||
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
|
||||
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<String> =
|
||||
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
|
||||
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
|
||||
warnings.push(format!(
|
||||
"{} skipped on {}{more}: only frames, components and instances have auto layout",
|
||||
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
super::props::apply("node.set", doc, i, &props)?;
|
||||
let props = if skipped.contains(&i) { &without_layout } else { &props };
|
||||
super::props::apply("node.set", doc, i, props)?;
|
||||
}
|
||||
if let Some(o) = pivot {
|
||||
for (i, q) in pinned {
|
||||
@@ -1097,7 +1122,43 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) }
|
||||
}
|
||||
|
||||
/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that
|
||||
/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is
|
||||
/// Bold), say which face it is. Families that aren't installed, and names that match a style or a
|
||||
/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing.
|
||||
fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map<String, Value>) -> Vec<String> {
|
||||
let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() };
|
||||
let mut families: Vec<String> = match props.get("fontFamily").and_then(Value::as_str) {
|
||||
Some(f) => vec![f.to_string()],
|
||||
None => ids
|
||||
.iter()
|
||||
.filter_map(|i| doc.node(*i))
|
||||
.filter_map(|n| n.kind.text())
|
||||
.flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::<Vec<_>>())
|
||||
.collect(),
|
||||
};
|
||||
families.sort();
|
||||
families.dedup();
|
||||
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
|
||||
let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic"));
|
||||
families
|
||||
.into_iter()
|
||||
.filter_map(|family| {
|
||||
let styles = figcraft_text::family_styles(&family);
|
||||
if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) {
|
||||
return None;
|
||||
}
|
||||
let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?;
|
||||
let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32);
|
||||
if face.instance(style).is_some() || (w == want_w && face.italic == want_i) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", ")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances
|
||||
|
||||
@@ -260,8 +260,41 @@ fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
|
||||
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The keys that set a frame's auto layout (through [`layout_mut`]).
|
||||
pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gap",
|
||||
"itemSpacing",
|
||||
"columnGap",
|
||||
"crossGap",
|
||||
"counterAxisSpacing",
|
||||
"rowGap",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"paddingHorizontal",
|
||||
"paddingVertical",
|
||||
"mainAlign",
|
||||
"primaryAxisAlignItems",
|
||||
"crossAlign",
|
||||
"counterAxisAlignItems",
|
||||
"contentAlign",
|
||||
"counterAxisAlignContent",
|
||||
"wrap",
|
||||
"gridColumns",
|
||||
"gridRows",
|
||||
"strokesIncluded",
|
||||
"reverseZ",
|
||||
"columnTracks",
|
||||
"gridColumnTracks",
|
||||
"rowTracks",
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it.
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
|
||||
/// (`node.set` on several layers skips them instead, see `edit::set`).
|
||||
fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLayout> {
|
||||
if !n.kind.is_frame_like() {
|
||||
return Err(bad(
|
||||
@@ -869,15 +902,6 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k
|
||||
}
|
||||
"fontStyle" => {
|
||||
let st = string_val(v).ok_or_else(|| bad(cmd, "`fontStyle` must be a string"))?;
|
||||
// A style the family doesn't have would draw in another face without a word; refuse it
|
||||
// and list the family's styles. A family that isn't installed keeps any name (files
|
||||
// from elsewhere name their fonts' styles).
|
||||
for (_, _, span) in t.spans().into_iter().filter(|(a, b, _)| *b > start && *a < end.max(start + 1)) {
|
||||
let styles = figcraft_text::family_styles(&span.font_family);
|
||||
if !styles.is_empty() && !styles.iter().any(|x| x.eq_ignore_ascii_case(&st)) {
|
||||
return Err(bad(cmd, format!("{} has no `{st}` style (it has: {})", span.font_family, styles.join(", "))));
|
||||
}
|
||||
}
|
||||
t.restyle(start, end, |s| s.set_font_style(st.clone()));
|
||||
}
|
||||
"fontWeight" => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `node.set` refuses what can't apply instead of storing it where nothing reads it: auto layout
|
||||
//! keys on layers that can't have auto layout, and a `fontStyle` the font family doesn't have.
|
||||
//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto
|
||||
//! layout (the others are named in `warnings`, and with none of them the call is refused), and a
|
||||
//! `fontStyle` the family lacks says which face draws instead.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -32,24 +33,53 @@ fn auto_layout_keys_on_layers_without_auto_layout_are_refused() {
|
||||
}
|
||||
// Frames still take them, with or without a layout mode first.
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]);
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_style_the_family_lacks_is_refused_with_the_styles_it_has() {
|
||||
fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]);
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text");
|
||||
assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}");
|
||||
// One undo step for all of it.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
// Any installed family with a known list of styles will do (CI machines differ).
|
||||
let Some((family, styles)) =
|
||||
figcraft_text::family_names().into_iter().map(|f| (f.clone(), figcraft_text::family_styles(&f))).find(|(_, st)| !st.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Any installed family with a regular and a bold face and nothing heavier (CI machines differ).
|
||||
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
|
||||
let st = figcraft_text::family_styles(&f);
|
||||
let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic"));
|
||||
(has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st))
|
||||
});
|
||||
let Some((family, styles)) = pick else { return };
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]);
|
||||
let msg = bad_msg(s.execute("node.set", &json!({"ids": [t], "props": {"fontStyle": "Definitely Not A Style"}})));
|
||||
assert!(msg.contains(&family) && msg.contains(&styles[0]), "{msg}");
|
||||
ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontStyle": styles[0]}}));
|
||||
// A family that isn't installed keeps any style name (files from elsewhere name theirs).
|
||||
ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
|
||||
let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}}));
|
||||
// Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing.
|
||||
let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone();
|
||||
for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] {
|
||||
let out = set(&mut s, &style);
|
||||
assert!(out.get("warnings").is_none(), "{style}: {out}");
|
||||
}
|
||||
// A heavier weight than the family has applies (as a text layer resolves it) and says which face draws.
|
||||
let out = set(&mut s, "Black");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}");
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black");
|
||||
// A family that isn't installed keeps any style name without a word (files from elsewhere name theirs).
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"set_properties",
|
||||
"Set properties",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only; on other layers they are an error, as is a fontStyle the font family doesn't have (the error lists its styles). Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
obj(
|
||||
json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}),
|
||||
&["props"],
|
||||
@@ -544,7 +544,7 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
|
||||
}
|
||||
params["ids"] = json!(ids);
|
||||
}
|
||||
exec(b, "node.set", params)?;
|
||||
let warnings = exec(b, "node.set", params)?.get("warnings").cloned();
|
||||
let ids = match ids {
|
||||
Some(i) => i,
|
||||
None => b
|
||||
@@ -555,7 +555,11 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let nodes: Vec<Value> = ids.iter().take(20).filter_map(|i| b.call("node.get", json!({"id": i, "depth": 0})).ok()).collect();
|
||||
Ok(ToolResult::json(&json!({"ok": true, "nodes": nodes})))
|
||||
let mut out = json!({"ok": true, "nodes": nodes});
|
||||
if let Some(w) = warnings {
|
||||
out["warnings"] = w;
|
||||
}
|
||||
Ok(ToolResult::json(&out))
|
||||
}
|
||||
|
||||
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
|
||||
|
||||
@@ -208,3 +208,14 @@ fn serve_accepts_a_limit_sized_frame_and_a_final_unterminated_frame() {
|
||||
assert_eq!(lines[0]["result"], json!({}));
|
||||
assert_eq!(lines[1]["id"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_properties_passes_on_what_it_skipped() {
|
||||
let mut c = Client::new();
|
||||
let f = c.ok("create_node", json!({"type": "frame"}))["id"].clone();
|
||||
let t = c.ok("create_node", json!({"type": "text", "text": "Hi"}))["id"].clone();
|
||||
let r = c.ok("set_properties", json!({"ids": [f, t], "props": {"gap": 8}}));
|
||||
assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("`gap`") && w.contains("TEXT")), "{r}");
|
||||
assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none());
|
||||
assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user