Files
figcraft/crates/engine/src/cmd/component/variants.rs
T
2026-11-08 01:47:50 -05:00

248 lines
12 KiB
Rust

use super::{
BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, create, id_of, inside_instance, json,
owning_instance, req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
};
/// Wrap components in a new component set (named after the first) placed around them.
pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?;
if inside_instance(doc, top) || owning_instance(doc, parent).is_some() {
return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into()));
}
let bounds = doc.bounds_of(ids).unwrap_or_default();
let pw = doc.world_transform(parent);
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0));
let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component"));
let set = doc.alloc_id();
let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height());
n.transform = figcraft_geom::Affine::translate((local.x0, local.y0));
n.fills.clear();
doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?;
for i in ids {
doc.reparent(*i, set, None)?;
}
Ok(set)
}
const INSIDE_INSTANCE: &str = "components inside an instance can't become variants";
/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they
/// can: anything but main components, components that are variants already (combining them would
/// put a set in a set), library copies, components inside an instance, and a component inside
/// another one of `ids`.
fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) {
Some("select components only")
} else if ids.iter().any(|i| variant_set(doc, *i).is_some()) {
Some("components that are variants already can't be combined again")
} else if ids.iter().any(|i| doc.is_remote(*i)) {
Some(crate::library::READ_ONLY)
} else if ids.iter().any(|i| inside_instance(doc, *i)) {
Some(INSIDE_INSTANCE)
} else if roots(doc, ids).len() != ids.len() {
Some("a component can't be combined with one inside it")
} else {
None
}
}
/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more
/// main components that [`combine_refusal`] lets through.
pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool {
ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::<BTreeSet<_>>().len() == ids.len()
}
/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main
/// component not in a set yet (it becomes one); never a library copy or inside an instance.
pub fn can_add_variant(doc: &Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
let local = !doc.is_remote(id) && !inside_instance(doc, id);
match n.kind {
NodeKind::ComponentSet { .. } => {
local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })))
}
NodeKind::Component { .. } => local,
_ => false,
}
}
/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default",
/// "Property 1=VariantN" unless already in prop=value form. Returns the set.
fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let set = wrap_in_set(cmd, doc, ids)?;
for (k, i) in ids.iter().enumerate() {
let node = doc.get_mut(*i)?;
if !node.name.contains('=') {
node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) });
}
}
Ok(set)
}
pub(super) fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.combineVariants";
let mut seen = BTreeSet::new();
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
let doc = s.doc_mut()?;
if let Some(why) = combine_refusal(doc, &ids) {
return Err(EngineError::Disabled(cmd.into(), why.into()));
}
let ids = z_sorted(doc, &ids);
let set = make_variant_set(cmd, doc, &ids)?;
s.set_selection(vec![set]);
Ok(json!({"id": set}))
}
/// Why `ids` (distinct) can't each become a component of a new set (`component.createSet`), or
/// `None` when they can: layers inside an instance, library copies, instances, components and
/// component sets, and a layer inside another one of `ids`.
fn create_set_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
if ids.iter().any(|i| inside_instance(doc, *i)) {
Some("layers inside an instance can't become components")
} else if ids.iter().any(|i| doc.is_remote(*i)) {
Some(crate::library::READ_ONLY)
} else if ids.iter().any(|i| {
doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
}) {
Some("select layers that aren't components, component sets or instances")
} else if roots(doc, ids).len() != ids.len() {
Some("a layer can't be in a set with one inside it")
} else {
None
}
}
/// Whether "Create component set" is on offer for `ids`: at least one layer that
/// [`create_set_refusal`] lets through.
pub fn can_create_set(doc: &Document, ids: &[NodeId]) -> bool {
let distinct: Vec<NodeId> = ids.iter().copied().collect::<BTreeSet<_>>().into_iter().collect();
!distinct.is_empty() && distinct.iter().all(|i| doc.contains(*i)) && create_set_refusal(doc, &distinct).is_none()
}
/// `component.createSet`: each target becomes a component (as `component.createMultiple` makes
/// them) and all are wrapped in place in a component set, 20 px around, named as Figma names them:
/// variants "Property 1=<layer name>" in left-to-right order (a name holding `=` is kept); layers
/// that all share a name give a set of that name with "Property 1=Default" variants; otherwise the
/// set is "Component N".
pub(super) fn create_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.createSet";
let mut seen = BTreeSet::new();
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
let doc = s.doc()?;
if let Some(why) = create_set_refusal(doc, &ids) {
return Err(EngineError::Disabled(cmd.into(), why.into()));
}
let mut made = Vec::new();
for i in roots(doc, &ids) {
let r = create(s, &json!({"ids": [i]}))?;
made.push(id_of(&r["id"]).ok_or_else(|| bad(cmd, "component creation failed"))?);
}
let doc = s.doc_mut()?;
// Left to right by world x, ties by y: the variants' order, and the values' order.
let mut by_x: Vec<(NodeId, f64, f64)> = made.iter().map(|i| doc.world_bounds(*i).map_or((*i, 0.0, 0.0), |b| (*i, b.x0, b.y0))).collect();
by_x.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)));
let names: Vec<String> = by_x.iter().map(|(i, ..)| doc.node(*i).map(|n| n.name.clone()).unwrap_or_default()).collect();
let same = names.windows(2).all(|w| w.first() == w.get(1)) && names.first().is_some_and(|n| !n.is_empty() && !n.contains('='));
let set_name = match names.first() {
Some(n) if same => n.clone(),
_ => doc.next_name("Component"),
};
for ((i, ..), name) in by_x.iter().zip(&names) {
let node = doc.get_mut(*i)?;
if same {
node.name = "Property 1=Default".into();
} else if !name.contains('=') {
node.name = format!("Property 1={name}");
}
}
let ordered = z_sorted(doc, &made);
let set = wrap_in_set(cmd, doc, &ordered)?;
doc.get_mut(set)?.name = set_name;
s.set_selection(vec![set]);
Ok(json!({"id": set}))
}
/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom).
pub(super) fn fit_set(doc: &mut Document, set: NodeId) {
let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return };
let (mut w, mut h) = (0.0f64, 0.0f64);
for c in kids {
if let Some(n) = doc.node(c) {
let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
w = w.max(b.x1 + 20.0);
h = h.max(b.y1 + 20.0);
}
}
if let Some(n) = doc.node_mut(set) {
n.width = n.width.max(w);
n.height = n.height.max(h);
}
}
pub(super) fn add_variant(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.addVariant";
let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?;
let doc = s.doc_mut()?;
let (set, base) = match doc.get(t)?.kind {
NodeKind::ComponentSet { .. } => {
let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?;
(t, last)
}
NodeKind::Component { .. } => match variant_set(doc, t) {
Some(set) => (set, t),
// A main component on its own becomes a set of itself, then gains the new variant.
None => (make_variant_set(cmd, doc, &[t])?, t),
},
_ => return Err(bad(cmd, "select a component set or component")),
};
let options = variant_options(doc, set);
let mut pairs = variant_props(&doc.get(base)?.name);
let order: Vec<String> = options.iter().map(|(k, _)| k.clone()).collect();
let existing: BTreeSet<BTreeMap<String, String>> =
doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect();
match (string(p, "property"), string(p, "value")) {
(Some(prop), Some(value)) => {
let (prop, value) = (prop.trim().to_string(), value.trim().to_string());
if !options.iter().any(|(k, _)| *k == prop) {
return Err(bad(cmd, format!("unknown variant property `{prop}`")));
}
if value.is_empty() || value.contains([',', '=']) {
return Err(bad(cmd, "`value` must be non-empty without ',' or '='"));
}
pairs.insert(prop, value);
if existing.contains(&pairs) {
return Err(bad(cmd, "that variant already exists"));
}
}
(Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")),
_ => {
let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into());
let mut k = existing.len() + 1;
loop {
pairs.insert(prop.clone(), format!("Variant{k}"));
if !existing.contains(&pairs) || k > 10_000 {
break;
}
k += 1;
}
}
}
let name = variant_name(&pairs, &order);
let bottom = doc
.get(set)?
.children
.iter()
.filter_map(|c| doc.node(*c))
.map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1)
.fold(0.0f64, f64::max);
let dup = doc.duplicate_into(base, set, None)?;
let n = doc.get_mut(dup)?;
n.name = name;
let c = n.transform.as_coeffs();
n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]);
fit_set(doc, set);
s.set_selection(vec![dup]);
Ok(json!({"id": dup}))
}