M3: reorder paints, effects and layout grids in the Design panel; grid and instance property variable bindings (#81)
Fill, stroke, effect and layout grid rows drag to reorder by their grip (a handle shows over the
row, an insertion line where it lands, the row's label follows the pointer like a dragged layer;
Escape cancels; lists that differ across the selection have nothing to drag). New commands
`fill.move`, `stroke.move` and `grid.move {ids?, from, to}` join `effect.move` (lists bottom first,
one undo step; items keep everything, linked list styles are unlinked).
Variable bindings on list items follow their items when a list is reordered or shrinks:
paint colours bind per paint (`fills/<i>/color`, `strokes/<i>/color`; `fill`/`stroke` stay the
first paint's), so a moved bound paint keeps its variable and its neighbours are never recoloured.
`fill.remove`, `stroke.remove` and `grid.remove` refuse negative indices.
Layout grids get settings in the Design panel (size; count, type, width or height, margin or
offset, gutter; colour) whose numbers bind to number variables (`layoutGrids/<i>/<field>`),
re-applied on variable and mode changes. Instance boolean and text properties bind to boolean and
string variables (`componentProperties/<name>`, from the instance's properties panel), kept across
instance rebuilds, following property renames and dropped on delete, reset and detach.
Inspect names the variable of each paint.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d16878588f
commit
4565c4ab93
|
|
@ -82,7 +82,7 @@
|
|||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Components that scale</h3>
|
||||
Main components, instances with overrides that survive edits, component sets with variants, properties, swap instance, detach, reset overrides and go to main component, and an Assets panel with live thumbnails, search and drag-to-canvas into frames and auto layout. Local styles and variables (colour, number, string, boolean) with modes, aliases and scopes, edited in a Variables table.
|
||||
Main components, instances with overrides that survive edits, component sets with variants, properties, swap instance, detach, reset overrides and go to main component, and an Assets panel with live thumbnails, search and drag-to-canvas into frames and auto layout. Local styles and variables (colour, number, string, boolean) with modes, aliases and scopes, edited in a Variables table and bound to fills, strokes, effects, layout grids and instance properties. Fills, strokes, effects and layout grids reorder by dragging their rows.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,10 @@ dimension measured side by side.
|
|||
"Library updates available" covers changed variables and bound layers follow). Explicit variable
|
||||
modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own, with
|
||||
enabled libraries' collections listed under the library's name (picking a mode copies the
|
||||
collection in). *Left:* QA with larger real-world libraries. 1 h.
|
||||
collection in). Instance boolean and text properties bound to boolean and string variables
|
||||
(following modes, renames, reset and detach); layout grid settings in the Design panel whose
|
||||
count, size, margin/offset and gutter bind to number variables; fills, strokes, effects and
|
||||
layout grids reordered by dragging their rows, their variable bindings following. *Left:* QA with larger real-world libraries. 1 h.
|
||||
5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor,
|
||||
flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter.
|
||||
Custom bezier and custom spring easings with editor fields; device frames (phone, tablet,
|
||||
|
|
@ -267,7 +270,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
|||
| Auto layout & constraints | 9 | 76% | grid `fr` rows around fixed-size children, QA with real-world files | 1–2 |
|
||||
| Text | 8 | 78% | list markers on the right of right-to-left paragraphs, visual QA | 1–2 |
|
||||
| Components & variants | 9 | 76% | QA with larger real-world libraries | 1 |
|
||||
| Styles & variables | 5 | 86% | grid and component property bindings, reordering paints and effects in the panel | 1–2 |
|
||||
| Styles & variables | 5 | 89% | variant properties and gradient stops bound to variables, REST import of bindings past the first paint and on layout grids, a colour picker for layout grid colours | 1 |
|
||||
| Prototyping | 6 | 86% | building conditionals in the interaction editor, hotspot hints, inspect in the presenter | 3–5 |
|
||||
| Import/export | 6 | 66% | .fig import (best effort), variable-mode lookups in prototype expressions, PDF print options (bleed, CMYK) | 10–20 |
|
||||
| Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 |
|
||||
|
|
|
|||
|
|
@ -433,6 +433,8 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.prop_refs.remove("mainComponent");
|
||||
// No properties left to drive.
|
||||
n.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
|
||||
for nn in nested {
|
||||
rebase_instance(doc, nn);
|
||||
}
|
||||
|
|
@ -471,6 +473,10 @@ fn reset_instance(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
{
|
||||
*props = defaults;
|
||||
}
|
||||
// ...and stop following variables.
|
||||
if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) {
|
||||
doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
|
||||
}
|
||||
done.push(inst);
|
||||
}
|
||||
if done.is_empty() {
|
||||
|
|
@ -797,6 +803,43 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
|
||||
/// boolean property takes a boolean variable, a text property a string one).
|
||||
pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> {
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else {
|
||||
return Err(format!("{id} is not an instance (component properties are bound on instances)"));
|
||||
};
|
||||
match (prop_defs(doc, *component).get(name), kind) {
|
||||
(Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()),
|
||||
(Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")),
|
||||
(Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")),
|
||||
(Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")),
|
||||
(None, _) => Err(format!("the instance has no property named `{name}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bound variable values into instance `id`'s boolean and text property values (only what
|
||||
/// changed; the instance then follows its new values like after `instance.setProperties`).
|
||||
pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) {
|
||||
let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return };
|
||||
let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return };
|
||||
let mut next = props.clone();
|
||||
for (name, val) in sets {
|
||||
let v = match (defs.get(name), val) {
|
||||
(Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b),
|
||||
(Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t),
|
||||
_ => continue,
|
||||
};
|
||||
next.insert(name.clone(), v);
|
||||
}
|
||||
if next != *props
|
||||
&& let Ok(n) = doc.get_mut(id)
|
||||
&& let NodeKind::Instance { props, .. } = &mut n.kind
|
||||
{
|
||||
*props = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Coerce a property value to the definition's type.
|
||||
fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result<Value> {
|
||||
match def {
|
||||
|
|
@ -1097,13 +1140,21 @@ fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&st
|
|||
}
|
||||
}
|
||||
}
|
||||
// Instances' values and the variables bound to them follow (a deleted property drops both).
|
||||
let old_key = format!("{PROP_BINDING}{old}");
|
||||
for inst in instances_of(doc, comp) {
|
||||
if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind
|
||||
let n = doc.get_mut(inst)?;
|
||||
if let NodeKind::Instance { props, .. } = &mut n.kind
|
||||
&& let Some(v) = props.remove(old)
|
||||
&& let Some(nw) = new
|
||||
{
|
||||
props.insert(nw.to_string(), v);
|
||||
}
|
||||
if let Some(var) = n.bound.remove(&old_key)
|
||||
&& let Some(nw) = new
|
||||
{
|
||||
n.bound.insert(format!("{PROP_BINDING}{nw}"), var);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,37 +215,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!("effect.remove", "Remove effect", "{ids?, index}", true, |s, p| {
|
||||
let i = req_index("effect.remove", p, "index")?;
|
||||
for id in req_targets("effect.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if i < n.effects.len() {
|
||||
n.effects.remove(i);
|
||||
// Its bindings go with it; later effects' move down one.
|
||||
super::styles::remap_effect_bindings(n, |k| match k.cmp(&i) {
|
||||
std::cmp::Ordering::Less => Some(k),
|
||||
std::cmp::Ordering::Equal => None,
|
||||
std::cmp::Ordering::Greater => Some(k - 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("effect.move", "Move effect", "{ids?, from, to} (indices in the effect list, bottom first)", true, |s, p| {
|
||||
let from = req_index("effect.move", p, "from")?;
|
||||
let to = req_index("effect.move", p, "to")?;
|
||||
for id in req_targets("effect.move", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if from >= n.effects.len() || to >= n.effects.len() || from == to {
|
||||
continue;
|
||||
}
|
||||
let e = n.effects.remove(from);
|
||||
n.effects.insert(to, e);
|
||||
// Bindings follow their effects.
|
||||
super::styles::remap_effect_bindings(n, |k| {
|
||||
if k == from {
|
||||
return Some(to);
|
||||
}
|
||||
let k = if k > from { k - 1 } else { k };
|
||||
Some(if k >= to { k + 1 } else { k })
|
||||
});
|
||||
// Its bindings go with it; later effects' move down one.
|
||||
super::lists::remove_item(s.doc_mut()?.get_mut(id)?, super::lists::BoundList::Effects, i);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
|
|
@ -268,12 +239,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
ok()
|
||||
}),
|
||||
spec!("grid.remove", "Remove layout grid", "{ids?, index}", true, |s, p| {
|
||||
let i = req_num("grid.remove", p, "index")? as usize;
|
||||
let i = req_index("grid.remove", p, "index")?;
|
||||
for id in req_targets("grid.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if i < n.grids.len() {
|
||||
n.grids.remove(i);
|
||||
}
|
||||
super::lists::remove_item(s.doc_mut()?.get_mut(id)?, super::lists::BoundList::Grids, i);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
|
|
@ -980,6 +948,7 @@ fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill" } else { "stroke" };
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) };
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
|
|
@ -994,17 +963,9 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Valu
|
|||
};
|
||||
list.push(paint);
|
||||
} else {
|
||||
let idx = num(p, "index").map(|x| x as usize).unwrap_or(list.len().saturating_sub(1));
|
||||
if idx < list.len() {
|
||||
list.remove(idx);
|
||||
// A binding on the first paint's colour belongs to the paint just removed.
|
||||
if idx == 0 {
|
||||
let keys: &[&str] = if fills { &["fill", "fills", "fills/0", "fills/0/color"] } else { &["stroke", "strokes/0/color"] };
|
||||
for k in keys {
|
||||
n.bound.remove(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
let idx = index.unwrap_or(list.len().saturating_sub(1));
|
||||
// The paint's colour binding goes with it; later paints' move down one.
|
||||
super::lists::remove_item(n, if fills { super::lists::BoundList::Fills } else { super::lists::BoundList::Strokes }, idx);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
//! A layer's ordered lists (fills, strokes, effects, layout grids): reordering them, and keeping
|
||||
//! the variable bindings on their items (`fills/<i>/color`, `effects/<i>/radius`,
|
||||
//! `layoutGrids/<i>/gutter`, …) with their items when a list is reordered or shrinks.
|
||||
//!
|
||||
//! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top;
|
||||
//! the Design panel shows them in reverse, the topmost row first.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{Result, Session, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("fill.move", "Move fill", "{ids?, from, to} (indices in the fill list, bottom first)", true, |s, p| {
|
||||
move_item(s, p, "fill.move", BoundList::Fills)
|
||||
}),
|
||||
spec!("stroke.move", "Move stroke", "{ids?, from, to} (indices in the stroke list, bottom first)", true, |s, p| {
|
||||
move_item(s, p, "stroke.move", BoundList::Strokes)
|
||||
}),
|
||||
spec!("effect.move", "Move effect", "{ids?, from, to} (indices in the effect list, bottom first)", true, |s, p| {
|
||||
move_item(s, p, "effect.move", BoundList::Effects)
|
||||
}),
|
||||
spec!("grid.move", "Move layout grid", "{ids?, from, to} (indices in the layout grid list, bottom first)", true, |s, p| {
|
||||
move_item(s, p, "grid.move", BoundList::Grids)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// One of a layer's ordered lists whose items carry variable bindings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BoundList {
|
||||
Fills,
|
||||
Strokes,
|
||||
Effects,
|
||||
Grids,
|
||||
}
|
||||
|
||||
impl BoundList {
|
||||
/// The binding keys' prefix (`<prefix>/<index>/<field>`).
|
||||
pub fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
BoundList::Fills => "fills",
|
||||
BoundList::Strokes => "strokes",
|
||||
BoundList::Effects => "effects",
|
||||
BoundList::Grids => "layoutGrids",
|
||||
}
|
||||
}
|
||||
|
||||
fn len(self, n: &Node) -> usize {
|
||||
match self {
|
||||
BoundList::Fills => n.fills.len(),
|
||||
BoundList::Strokes => n.strokes.len(),
|
||||
BoundList::Effects => n.effects.len(),
|
||||
BoundList::Grids => n.grids.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlink the style that defined the whole list (it no longer matches it).
|
||||
fn detach_style(self, n: &mut Node) {
|
||||
match self {
|
||||
BoundList::Fills => n.styles.fill = None,
|
||||
BoundList::Strokes => n.styles.stroke = None,
|
||||
BoundList::Effects => n.styles.effect = None,
|
||||
BoundList::Grids => n.styles.grid = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The binding key of `field` on item `index` (`fills/2/color`).
|
||||
pub fn key(self, index: usize, field: &str) -> String {
|
||||
format!("{}/{index}/{field}", self.prefix())
|
||||
}
|
||||
}
|
||||
|
||||
/// The list item a binding key refers to: its list, index and field. `fill`, `fills` and
|
||||
/// `fills/0` (and `stroke`) are the first paint's colour.
|
||||
pub fn list_binding(key: &str) -> Option<(BoundList, usize, &str)> {
|
||||
match key {
|
||||
"fill" | "fills" | "fills/0" => return Some((BoundList::Fills, 0, "color")),
|
||||
"stroke" => return Some((BoundList::Strokes, 0, "color")),
|
||||
_ => {}
|
||||
}
|
||||
let (prefix, rest) = key.split_once('/')?;
|
||||
let list = [BoundList::Fills, BoundList::Strokes, BoundList::Effects, BoundList::Grids].into_iter().find(|l| l.prefix() == prefix)?;
|
||||
let (i, field) = rest.split_once('/')?;
|
||||
// Plain digits only (no sign, no "+1").
|
||||
if i.is_empty() || !i.bytes().all(|b| b.is_ascii_digit()) || field.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((list, i.parse().ok()?, field))
|
||||
}
|
||||
|
||||
/// The binding key on paint `index`'s colour that `n` uses, if any (`fill` for the first fill's,
|
||||
/// or `fills/<index>/color`).
|
||||
pub fn paint_binding_key(n: &Node, fills: bool, index: usize) -> Option<&str> {
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
n.bound.keys().map(String::as_str).find(|k| list_binding(k) == Some((list, index, "color")))
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `from` moves to `to`.
|
||||
pub fn moved_index(k: usize, from: usize, to: usize) -> usize {
|
||||
if k == from {
|
||||
return to;
|
||||
}
|
||||
let k = if k > from { k - 1 } else { k };
|
||||
if k >= to { k + 1 } else { k }
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `removed` goes (None: it is the one removed).
|
||||
pub fn removed_index(k: usize, removed: usize) -> Option<usize> {
|
||||
match k.cmp(&removed) {
|
||||
std::cmp::Ordering::Less => Some(k),
|
||||
std::cmp::Ordering::Equal => None,
|
||||
std::cmp::Ordering::Greater => Some(k - 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `n`'s bindings on `list`'s items with their items when the list changes: `map` gives an
|
||||
/// item's new index, or None when it was removed (its bindings go with it). Bindings whose item
|
||||
/// keeps its place keep their key.
|
||||
pub(crate) fn remap_list_bindings(n: &mut Node, list: BoundList, map: impl Fn(usize) -> Option<usize>) {
|
||||
let moved: Vec<(String, Option<String>)> = n
|
||||
.bound
|
||||
.keys()
|
||||
.filter_map(|k| {
|
||||
let (l, i, field) = list_binding(k).filter(|(l, _, _)| *l == list)?;
|
||||
match map(i) {
|
||||
Some(j) if j == i => None,
|
||||
Some(j) => Some((k.clone(), Some(l.key(j, field)))),
|
||||
None => Some((k.clone(), None)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut kept = Vec::new();
|
||||
for (old, new) in moved {
|
||||
if let Some(var) = n.bound.remove(&old)
|
||||
&& let Some(new) = new
|
||||
{
|
||||
kept.push((new, var));
|
||||
}
|
||||
}
|
||||
n.bound.extend(kept);
|
||||
}
|
||||
|
||||
/// Move `v[from]` to `to`; false (and nothing changes) when either is out of range or they match.
|
||||
fn reorder<T>(v: &mut Vec<T>, from: usize, to: usize) -> bool {
|
||||
if from >= v.len() || to >= v.len() || from == to {
|
||||
return false;
|
||||
}
|
||||
let item = v.remove(from);
|
||||
v.insert(to, item);
|
||||
true
|
||||
}
|
||||
|
||||
/// `fill.move` / `stroke.move` / `effect.move` / `grid.move`: the item keeps everything (an image
|
||||
/// fill its image and transform, a gradient its stops) and its bindings go with it.
|
||||
fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result<Value> {
|
||||
let from = req_index(cmd, p, "from")?;
|
||||
let to = req_index(cmd, p, "to")?;
|
||||
for id in req_targets(cmd, s, p)? {
|
||||
let len = s.doc()?.node(id).map(|n| list.len(n)).unwrap_or(0);
|
||||
if from >= len || to >= len || from == to {
|
||||
continue;
|
||||
}
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
let moved = match list {
|
||||
BoundList::Fills => reorder(&mut n.fills, from, to),
|
||||
BoundList::Strokes => reorder(&mut n.strokes, from, to),
|
||||
BoundList::Effects => reorder(&mut n.effects, from, to),
|
||||
BoundList::Grids => reorder(&mut n.grids, from, to),
|
||||
};
|
||||
if moved {
|
||||
remap_list_bindings(n, list, |k| Some(moved_index(k, from, to)));
|
||||
list.detach_style(n);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Drop item `index` of `list` on `n` with its bindings (later items' bindings move down one).
|
||||
/// False when there is no such item.
|
||||
pub(crate) fn remove_item(n: &mut Node, list: BoundList, index: usize) -> bool {
|
||||
if index >= list.len(n) {
|
||||
return false;
|
||||
}
|
||||
match list {
|
||||
BoundList::Fills => {
|
||||
let _ = n.fills.remove(index);
|
||||
}
|
||||
BoundList::Strokes => {
|
||||
let _ = n.strokes.remove(index);
|
||||
}
|
||||
BoundList::Effects => {
|
||||
let _ = n.effects.remove(index);
|
||||
}
|
||||
BoundList::Grids => {
|
||||
let _ = n.grids.remove(index);
|
||||
}
|
||||
}
|
||||
remap_list_bindings(n, list, |k| removed_index(k, index));
|
||||
true
|
||||
}
|
||||
|
||||
/// Write bound numbers into a node's layout grids (`layoutGrids/<i>/<size|count|gutter|offset>`;
|
||||
/// only what changed, so unchanged layers stay shared).
|
||||
pub(crate) fn apply_grid_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, f64)]) {
|
||||
let Some(mut grids) = doc.node(id).map(|n| n.grids.clone()) else { return };
|
||||
for (i, field, x) in sets {
|
||||
let Some(g) = grids.get_mut(*i).filter(|_| x.is_finite()) else { continue };
|
||||
match field.as_str() {
|
||||
"size" => g.size = x.max(0.0),
|
||||
"count" => g.count = x.round().clamp(1.0, 1000.0) as u32,
|
||||
"gutter" => g.gutter = x.max(0.0),
|
||||
"offset" => g.offset = *x,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if doc.node(id).is_some_and(|n| n.grids != grids)
|
||||
&& let Ok(n) = doc.get_mut(id)
|
||||
{
|
||||
n.grids = grids;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ pub mod file;
|
|||
pub mod image;
|
||||
pub mod layout;
|
||||
pub mod library;
|
||||
pub mod lists;
|
||||
pub mod page;
|
||||
pub mod props;
|
||||
pub mod proto;
|
||||
|
|
@ -67,6 +68,7 @@ fn registry() -> &'static Registry {
|
|||
let mut list = Vec::new();
|
||||
list.extend(create::specs());
|
||||
list.extend(edit::specs());
|
||||
list.extend(lists::specs());
|
||||
list.extend(arrange::specs());
|
||||
list.extend(structure::specs());
|
||||
list.extend(section::specs());
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"variable.bind",
|
||||
"Bind variable",
|
||||
"{ids?, field (fill|stroke|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…), variable (null unbinds)}",
|
||||
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|effects/<i>/<offsetX|offsetY|radius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
|
||||
true,
|
||||
bind
|
||||
),
|
||||
|
|
@ -513,6 +513,12 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(v) => Some(doc.variables.get(v).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such variable"))?),
|
||||
None => None,
|
||||
};
|
||||
// An instance's component property takes a variable of its type.
|
||||
if let (Some(name), Some(v)) = (field.strip_prefix(crate::components::PROP_BINDING), vid.as_ref().and_then(|v| doc.variables.get(v))) {
|
||||
for i in &ids {
|
||||
super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?;
|
||||
}
|
||||
}
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
match &vid {
|
||||
|
|
@ -645,6 +651,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// Write bound variable values into nodes (called during normalisation).
|
||||
pub fn apply_bindings(doc: &mut Document) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -655,24 +662,26 @@ pub fn apply_bindings(doc: &mut Document) {
|
|||
let binds = n.bound.clone();
|
||||
let mut sets = serde_json::Map::new();
|
||||
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
|
||||
let mut paint_sets: Vec<(bool, Color)> = Vec::new();
|
||||
let mut paint_sets: Vec<(bool, usize, Color)> = Vec::new();
|
||||
let mut grid_sets: Vec<(usize, String, f64)> = Vec::new();
|
||||
let mut prop_sets: Vec<(String, VariableValue)> = Vec::new();
|
||||
for (field, var) in binds {
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// The first fill's or stroke's colour (the rest of the list stays).
|
||||
let paint = match field.as_str() {
|
||||
"fill" | "fills" | "fills/0" | "fills/0/color" => Some(true),
|
||||
"stroke" | "strokes/0/color" => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(fills), VariableValue::Color(c)) = (paint, &val) {
|
||||
paint_sets.push((fills, *c));
|
||||
// One list item's value: a paint's colour, an effect's or a layout grid's field.
|
||||
if let Some((list, i, prop)) = list_binding(&field) {
|
||||
match (list, &val) {
|
||||
(BoundList::Fills | BoundList::Strokes, VariableValue::Color(c)) if prop == "color" => {
|
||||
paint_sets.push((list == BoundList::Fills, i, *c));
|
||||
}
|
||||
(BoundList::Effects, _) => effect_sets.push((i, prop.to_string(), val)),
|
||||
(BoundList::Grids, VariableValue::Number(x)) => grid_sets.push((i, prop.to_string(), *x)),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// `effects/<index>/<offsetX|offsetY|radius|spread|color>`: one effect's value.
|
||||
if let Some((i, prop)) = field.strip_prefix("effects/").and_then(|r| r.split_once('/'))
|
||||
&& let Ok(i) = i.parse::<usize>()
|
||||
{
|
||||
effect_sets.push((i, prop.to_string(), val));
|
||||
// `componentProperties/<name>`: an instance's boolean or text property value.
|
||||
if let Some(name) = field.strip_prefix(crate::components::PROP_BINDING) {
|
||||
prop_sets.push((name.to_string(), val));
|
||||
continue;
|
||||
}
|
||||
let jv = match &val {
|
||||
|
|
@ -684,34 +693,38 @@ pub fn apply_bindings(doc: &mut Document) {
|
|||
VariableValue::Boolean(b) => json!(b),
|
||||
VariableValue::Alias(_) => continue,
|
||||
};
|
||||
let key = match field.as_str() {
|
||||
"fill" | "fills" | "fills/0" | "fills/0/color" => "fill",
|
||||
"stroke" | "strokes/0/color" => "stroke",
|
||||
other => other,
|
||||
};
|
||||
sets.insert(key.to_string(), jv);
|
||||
sets.insert(field, jv);
|
||||
}
|
||||
if !sets.is_empty() {
|
||||
let _ = super::props::apply("variables", doc, id, &sets);
|
||||
}
|
||||
let _ = super::props::apply("variables", doc, id, &sets);
|
||||
if !paint_sets.is_empty() {
|
||||
apply_paint_bindings(doc, id, &paint_sets);
|
||||
}
|
||||
if !effect_sets.is_empty() {
|
||||
apply_effect_bindings(doc, id, &effect_sets);
|
||||
}
|
||||
if !grid_sets.is_empty() {
|
||||
super::lists::apply_grid_bindings(doc, id, &grid_sets);
|
||||
}
|
||||
if !prop_sets.is_empty() {
|
||||
super::component::apply_prop_bindings(doc, id, &prop_sets);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bound colours into a node's first fill (`true`) or stroke, keeping the rest of the list and
|
||||
/// the paint's opacity, visibility and blend mode (only what changed, so unchanged layers stay
|
||||
/// shared).
|
||||
fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, Color)]) {
|
||||
/// Write bound colours into a node's fills (`true`) or strokes by index, keeping the rest of the
|
||||
/// list and each paint's opacity, visibility and blend mode (only what changed, so unchanged layers
|
||||
/// stay shared). A first-paint binding on an empty list adds the paint.
|
||||
fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, usize, Color)]) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
let (mut fills, mut strokes) = (n.fills.clone(), n.strokes.clone());
|
||||
for (is_fill, c) in sets {
|
||||
for (is_fill, i, c) in sets {
|
||||
let list = if *is_fill { &mut fills } else { &mut strokes };
|
||||
match list.first_mut() {
|
||||
match list.get_mut(*i) {
|
||||
Some(p) => p.kind = PaintKind::Solid { color: *c },
|
||||
None => list.push(Paint::solid(*c)),
|
||||
None if *i == 0 => list.push(Paint::solid(*c)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if doc.node(id).is_some_and(|n| n.fills != fills || n.strokes != strokes)
|
||||
|
|
@ -722,29 +735,6 @@ fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, Color)])
|
|||
}
|
||||
}
|
||||
|
||||
/// Keep a node's bindings on its effects' fields (`effects/<index>/<field>`) with their effects
|
||||
/// when the list changes: `map` gives an effect's new index, or None when it was removed.
|
||||
pub(crate) fn remap_effect_bindings(n: &mut Node, map: impl Fn(usize) -> Option<usize>) {
|
||||
let moved: Vec<(String, Option<String>)> = n
|
||||
.bound
|
||||
.keys()
|
||||
.filter_map(|k| {
|
||||
let (i, field) = k.strip_prefix("effects/")?.split_once('/')?;
|
||||
let i = i.parse::<usize>().ok()?;
|
||||
Some((k.clone(), map(i).map(|j| format!("effects/{j}/{field}"))))
|
||||
})
|
||||
.collect();
|
||||
let mut kept = Vec::new();
|
||||
for (old, new) in moved {
|
||||
if let Some(var) = n.bound.remove(&old)
|
||||
&& let Some(new) = new
|
||||
{
|
||||
kept.push((new, var));
|
||||
}
|
||||
}
|
||||
n.bound.extend(kept);
|
||||
}
|
||||
|
||||
/// Write bound values into a node's effects (only what changed, so unchanged layers stay shared).
|
||||
fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, VariableValue)]) {
|
||||
let Some(mut effects) = doc.node(id).map(|n| n.effects.clone()) else { return };
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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};
|
||||
|
|
@ -237,14 +238,17 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
|
|||
"characters" => "TEXT_CONTENT",
|
||||
"fontFamily" => "FONT_FAMILY",
|
||||
"fontStyle" => "FONT_STYLE",
|
||||
"stroke" | "strokes/0/color" => "STROKE_COLOR",
|
||||
f if f.starts_with("effects/") && f.ends_with("/color") => "EFFECT_COLOR",
|
||||
f if f.starts_with("effects/") => "EFFECT_FLOAT",
|
||||
"fill" | "fills" | "fills/0" | "fills/0/color" => match doc.node(node).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(_)) => "TEXT_FILL",
|
||||
Some(k) if k.is_frame_like() => "FRAME_FILL",
|
||||
_ => "SHAPE_FILL",
|
||||
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,
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
|
|||
if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) {
|
||||
*b = a.clone();
|
||||
}
|
||||
carry_prop_bindings(from, to);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -382,6 +383,18 @@ fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap<NodeId, S
|
|||
out
|
||||
}
|
||||
|
||||
/// The binding key prefix of an instance's component property value
|
||||
/// (`componentProperties/<name>`): a boolean property takes a boolean variable, a text property a
|
||||
/// string variable.
|
||||
pub const PROP_BINDING: &str = "componentProperties/";
|
||||
|
||||
/// Give `to` the component property bindings of instance `from` (they go with its property
|
||||
/// values, which an instance keeps across rebuilds).
|
||||
fn carry_prop_bindings(from: &Node, to: &mut Node) {
|
||||
to.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
|
||||
to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
|
||||
/// The override group a property-bound field belongs to.
|
||||
fn field_group(field: &str) -> &'static str {
|
||||
match field {
|
||||
|
|
@ -619,6 +632,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
|
|||
fresh.children = order;
|
||||
fresh.transform = root.transform;
|
||||
fresh.kind = root.kind.clone();
|
||||
carry_prop_bindings(&root, &mut fresh);
|
||||
// A nested instance keeps mirroring its layer of the outer main.
|
||||
fresh.source = if inside_instance(doc, inst) { root.source } else { Some(component) };
|
||||
fresh.constraints = root.constraints;
|
||||
|
|
|
|||
|
|
@ -213,42 +213,29 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
|||
if let Some(t) = n.kind.text() {
|
||||
out.push(typography(doc, n, t));
|
||||
}
|
||||
let paint_rows = |paints: &[figcraft_color::Paint]| -> Vec<Prop> {
|
||||
paints
|
||||
// Each paint's row names the variable bound to its colour; a style names the first row.
|
||||
let paint_rows = |fills: bool, style: Option<&StyleId>| -> Vec<Prop> {
|
||||
let paints = if fills { &n.fills } else { &n.strokes };
|
||||
let rows: Vec<Prop> = paints
|
||||
.iter()
|
||||
.filter(|p| p.visible)
|
||||
.map(|p| match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let c = color.with_alpha(color.a * p.opacity);
|
||||
prop("Solid", color_text(c)).color(c)
|
||||
}
|
||||
PaintKind::Gradient(g) => {
|
||||
let kind = match g.kind {
|
||||
GradientKind::Linear => "Linear",
|
||||
GradientKind::Radial => "Radial",
|
||||
GradientKind::Angular => "Angular",
|
||||
GradientKind::Diamond => "Diamond",
|
||||
};
|
||||
let stops: Vec<String> = g.stops.iter().map(|s| format!("#{} {}%", s.color.to_hex(), (s.position * 100.0).round())).collect();
|
||||
let r = prop(kind, stops.join(", "));
|
||||
match g.stops.first() {
|
||||
Some(s) => r.color(s.color),
|
||||
None => r,
|
||||
}
|
||||
}
|
||||
PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())),
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.visible)
|
||||
.map(|(i, p)| {
|
||||
let token = crate::cmd::lists::paint_binding_key(n, fills, i).and_then(|k| variable(doc, n, k));
|
||||
paint_row(p).token(token)
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
match style_name(doc, style) {
|
||||
Some(name) => tag_first(rows, Some(name)),
|
||||
None => rows,
|
||||
}
|
||||
};
|
||||
let fills = paint_rows(&n.fills);
|
||||
let fills = paint_rows(true, n.styles.fill.as_ref());
|
||||
if !fills.is_empty() {
|
||||
let token = style_name(doc, n.styles.fill.as_ref()).or_else(|| variable(doc, n, "fill"));
|
||||
out.push(Section { title: "Fills", rows: tag_first(fills, token) });
|
||||
out.push(Section { title: "Fills", rows: fills });
|
||||
}
|
||||
let mut strokes = paint_rows(&n.strokes);
|
||||
let mut strokes = paint_rows(false, n.styles.stroke.as_ref());
|
||||
if !strokes.is_empty() {
|
||||
let token = style_name(doc, n.styles.stroke.as_ref()).or_else(|| variable(doc, n, "stroke"));
|
||||
strokes = tag_first(strokes, token);
|
||||
let align = match n.stroke.align {
|
||||
StrokeAlign::Inside => "inside",
|
||||
StrokeAlign::Center => "center",
|
||||
|
|
@ -306,6 +293,31 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
|||
}
|
||||
|
||||
/// A style or variable applies to the whole list: show it on the first row.
|
||||
/// One paint's row: its kind and colour (or stops, or opacity for images).
|
||||
fn paint_row(p: &figcraft_color::Paint) -> Prop {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let c = color.with_alpha(color.a * p.opacity);
|
||||
prop("Solid", color_text(c)).color(c)
|
||||
}
|
||||
PaintKind::Gradient(g) => {
|
||||
let kind = match g.kind {
|
||||
GradientKind::Linear => "Linear",
|
||||
GradientKind::Radial => "Radial",
|
||||
GradientKind::Angular => "Angular",
|
||||
GradientKind::Diamond => "Diamond",
|
||||
};
|
||||
let stops: Vec<String> = g.stops.iter().map(|s| format!("#{} {}%", s.color.to_hex(), (s.position * 100.0).round())).collect();
|
||||
let r = prop(kind, stops.join(", "));
|
||||
match g.stops.first() {
|
||||
Some(s) => r.color(s.color),
|
||||
None => r,
|
||||
}
|
||||
}
|
||||
PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())),
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_first(mut rows: Vec<Prop>, token: Option<String>) -> Vec<Prop> {
|
||||
if let Some(first) = rows.first_mut() {
|
||||
first.token = token;
|
||||
|
|
|
|||
|
|
@ -974,6 +974,8 @@ mod measure_tests;
|
|||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
//! Reordering paints, effects and layout grids (`fill.move`, `stroke.move`, `effect.move`,
|
||||
//! `grid.move`): one undo step each, items keep everything, variable bindings and style links
|
||||
//! follow; layout grid fields and instances' component properties bound to variables.
|
||||
|
||||
use figcraft_color::{Color, Gradient, GradientKind, ImageScaleMode, Paint};
|
||||
use figcraft_doc::{GridKind, LayoutGrid, Node, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})))
|
||||
}
|
||||
|
||||
fn hexes(paints: &[Paint]) -> Vec<String> {
|
||||
paints.iter().map(|p| p.solid_color().map(|c| c.to_hex()).unwrap_or_else(|| "-".into())).collect()
|
||||
}
|
||||
|
||||
fn vars(s: &mut Session) {
|
||||
ex(s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]}));
|
||||
for (name, kind, value) in [("brand", "color", json!("#FF0000")), ("gap", "number", json!(12)), ("cols", "number", json!(6))] {
|
||||
ex(s, "variable.create", json!({"collection": "T", "name": name, "type": kind, "value": value}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_move_reorders_as_one_undo_step_and_keeps_every_paint() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let gradient = Paint::gradient(Gradient::default_for(GradientKind::Radial, Color::from_hex("#00FF00").unwrap()));
|
||||
let mut image = Paint::image("synthetic-hash", ImageScaleMode::Fit);
|
||||
image.opacity = 0.4;
|
||||
let mut red = Paint::solid(Color::from_hex("#FF0000").unwrap());
|
||||
red.visible = false;
|
||||
let fills = vec![red.clone(), gradient.clone(), image.clone()];
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": fills}}));
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
// Bottom (0) to top (2).
|
||||
ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 2}));
|
||||
assert_eq!(node(&s, r).fills, vec![gradient.clone(), image.clone(), red.clone()], "image, gradient and hidden paint survive whole");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
ex(&mut s, "fill.move", json!({"ids": [r], "from": 2, "to": 1}));
|
||||
assert_eq!(node(&s, r).fills, vec![gradient.clone(), red.clone(), image.clone()]);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, r).fills, fills);
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(node(&s, r).fills, vec![gradient, image, red]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_out_of_range_change_nothing_and_junk_is_refused() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
|
||||
let before = node(&s, r).fills;
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
for cmd in ["fill.move", "stroke.move", "effect.move", "grid.move"] {
|
||||
// Nothing to move: no change.
|
||||
ex(&mut s, cmd, json!({"ids": [r], "from": 0, "to": 9}));
|
||||
ex(&mut s, cmd, json!({"ids": [r], "from": 1, "to": 1}));
|
||||
for bad in [json!({"ids": [r], "from": -1, "to": 0}), json!({"ids": [r], "from": 0.5, "to": 1}), json!({"ids": [r], "from": 0})] {
|
||||
assert!(s.execute(cmd, &bad).is_err(), "{cmd} {bad}");
|
||||
}
|
||||
}
|
||||
assert_eq!(node(&s, r).fills, before);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "no-op moves add no undo steps");
|
||||
// A negative index never removes the first paint or grid.
|
||||
assert!(s.execute("fill.remove", &json!({"ids": [r], "index": -1})).is_err());
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"grids": [LayoutGrid::default()]}}));
|
||||
assert!(s.execute("grid.remove", &json!({"ids": [r], "index": -1})).is_err());
|
||||
assert_eq!(node(&s, r).fills, before);
|
||||
assert_eq!(node(&s, r).grids.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_paint_colour_follows_its_paint() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
vars(&mut s);
|
||||
ex(&mut s, "fill.set", json!({"ids": [r], "index": 0, "color": "#0000FF"}));
|
||||
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
|
||||
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#FFFFFF"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
assert_eq!(hexes(&node(&s, r).fills), ["FF0000", "00FF00", "FFFFFF"]);
|
||||
// The bound bottom paint goes to the top: the binding goes with it.
|
||||
ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 2}));
|
||||
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["fills/2/color"]);
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert_eq!(hexes(&node(&s, r).fills), ["00FF00", "FFFFFF", "123456"], "the neighbours aren't recoloured");
|
||||
// Modes recolour the moved paint too.
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "mode": "Dark", "value": "#ABCDEF"}));
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "T", "mode": "Dark", "ids": [r]}));
|
||||
assert_eq!(hexes(&node(&s, r).fills), ["00FF00", "FFFFFF", "ABCDEF"]);
|
||||
// Removing a paint below shifts the binding down with its paint.
|
||||
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
|
||||
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["fills/1/color"]);
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "mode": "Dark", "value": "#010203"}));
|
||||
assert_eq!(hexes(&node(&s, r).fills), ["FFFFFF", "010203"]);
|
||||
// Undo brings back the binding where it was.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["fills/2/color"]);
|
||||
// Removing the bound paint drops its binding.
|
||||
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 2}));
|
||||
assert!(node(&s, r).bound.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_move_with_their_bindings_and_moves_unlink_styles() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = (rect(&mut s), rect(&mut s));
|
||||
vars(&mut s);
|
||||
for r in [a, b] {
|
||||
ex(&mut s, "stroke.add", json!({"ids": [r], "color": "#000000"}));
|
||||
ex(&mut s, "stroke.add", json!({"ids": [r], "color": "#00FF00"}));
|
||||
}
|
||||
ex(&mut s, "variable.bind", json!({"ids": [a, b], "field": "stroke", "variable": "brand"}));
|
||||
ex(&mut s, "style.create", json!({"type": "paint", "name": "Two", "from": a}));
|
||||
ex(&mut s, "style.apply", json!({"ids": [a, b], "style": "Two"}));
|
||||
assert!(node(&s, a).styles.fill.is_some());
|
||||
ex(&mut s, "fill.add", json!({"ids": [a, b], "color": "#FFFFFF"}));
|
||||
// One command moves the strokes of every selected layer.
|
||||
ex(&mut s, "stroke.move", json!({"ids": [a, b], "from": 0, "to": 1}));
|
||||
for r in [a, b] {
|
||||
assert_eq!(hexes(&node(&s, r).strokes), ["00FF00", "FF0000"]);
|
||||
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["strokes/1/color"]);
|
||||
}
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
|
||||
assert_eq!(hexes(&node(&s, b).strokes), ["00FF00", "0000FF"]);
|
||||
// A paint style defined the whole fill list: reordering it unlinks the style.
|
||||
let fills = node(&s, a).fills.len();
|
||||
assert!(fills >= 2);
|
||||
ex(&mut s, "fill.move", json!({"ids": [a], "from": 0, "to": fills - 1}));
|
||||
assert!(node(&s, a).styles.fill.is_none());
|
||||
assert!(node(&s, b).styles.fill.is_some(), "only the moved layer unlinks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_paint_keys_resolve_and_scope_by_index() {
|
||||
use crate::cmd::lists::{BoundList, list_binding};
|
||||
assert_eq!(list_binding("fill"), Some((BoundList::Fills, 0, "color")));
|
||||
assert_eq!(list_binding("stroke"), Some((BoundList::Strokes, 0, "color")));
|
||||
assert_eq!(list_binding("fills/3/color"), Some((BoundList::Fills, 3, "color")));
|
||||
assert_eq!(list_binding("layoutGrids/1/gutter"), Some((BoundList::Grids, 1, "gutter")));
|
||||
for junk in
|
||||
["fills/-1/color", "fills/+1/color", "fills//color", "fills/1/", "effects/x/radius", "grids/0/size", "fills/99999999999999999999999/color"]
|
||||
{
|
||||
assert_eq!(list_binding(junk), None, "{junk}");
|
||||
}
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let doc = s.doc().unwrap();
|
||||
use crate::cmd::variables::field_scope;
|
||||
assert_eq!(field_scope(doc, r, "fills/2/color"), Some("SHAPE_FILL"));
|
||||
assert_eq!(field_scope(doc, r, "strokes/1/color"), Some("STROKE_COLOR"));
|
||||
assert_eq!(field_scope(doc, r, "effects/0/color"), Some("EFFECT_COLOR"));
|
||||
assert_eq!(field_scope(doc, r, "layoutGrids/0/count"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_names_the_variable_of_each_paint() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
vars(&mut s);
|
||||
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 1}));
|
||||
let props = crate::devinspect::properties(s.doc().unwrap(), r);
|
||||
let fills = props.iter().find(|p| p.title == "Fills").unwrap();
|
||||
let tokens: Vec<Option<&str>> = fills.rows.iter().map(|p| p.token.as_deref()).collect();
|
||||
assert_eq!(tokens, [None, Some("brand")], "the token follows the moved paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_grid_fields_bind_to_number_variables_and_follow_moves() {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300})));
|
||||
vars(&mut s);
|
||||
ex(&mut s, "grid.add", json!({"ids": [f], "grid": {"kind": "columns", "count": 4, "gutter": 20}}));
|
||||
ex(&mut s, "grid.add", json!({"ids": [f], "grid": {"kind": "rows", "count": 3}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "layoutGrids/0/gutter", "variable": "gap"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "layoutGrids/0/count", "variable": "cols"}));
|
||||
let g = node(&s, f).grids[0].clone();
|
||||
assert_eq!((g.kind, g.count, g.gutter), (GridKind::Columns, 6, 12.0));
|
||||
// Values and modes flow in.
|
||||
ex(&mut s, "variable.set", json!({"variable": "gap", "mode": "Dark", "value": 32}));
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "T", "mode": "Dark", "ids": [f]}));
|
||||
assert_eq!(node(&s, f).grids[0].gutter, 32.0);
|
||||
// Junk numbers are kept in range.
|
||||
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": -5}));
|
||||
assert_eq!(node(&s, f).grids[0].count, 1);
|
||||
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": 1e12}));
|
||||
assert_eq!(node(&s, f).grids[0].count, 1000);
|
||||
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": 5}));
|
||||
// Move the bound grid on top: its bindings go with it, the rows grid isn't touched.
|
||||
ex(&mut s, "grid.move", json!({"ids": [f], "from": 0, "to": 1}));
|
||||
let keys: Vec<String> = node(&s, f).bound.keys().cloned().collect();
|
||||
assert_eq!(keys, ["layoutGrids/1/count", "layoutGrids/1/gutter"]);
|
||||
ex(&mut s, "variable.set", json!({"variable": "gap", "mode": "Dark", "value": 8}));
|
||||
let gs = node(&s, f).grids;
|
||||
assert_eq!((gs[0].kind, gs[0].count), (GridKind::Rows, 3));
|
||||
assert_eq!((gs[1].kind, gs[1].count, gs[1].gutter), (GridKind::Columns, 5, 8.0));
|
||||
// Removing the grid below shifts them down; removing the bound grid drops them.
|
||||
ex(&mut s, "grid.remove", json!({"ids": [f], "index": 0}));
|
||||
assert!(node(&s, f).bound.keys().all(|k| k.starts_with("layoutGrids/0/")));
|
||||
ex(&mut s, "grid.remove", json!({"ids": [f], "index": 0}));
|
||||
assert!(node(&s, f).bound.is_empty());
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, f).bound.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_properties_bind_to_boolean_and_string_variables() {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let title = id(ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f})));
|
||||
let dot = id(ex(&mut s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f})));
|
||||
let c = id(ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
|
||||
let i = id(ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let copy = |s: &Session, src: NodeId| {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.subtree(i).into_iter().find(|x| doc.node(*x).and_then(|n| n.source) == Some(src)).unwrap()
|
||||
};
|
||||
let (ititle, idot) = (copy(&s, title), copy(&s, dot));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Flags", "modes": ["Short", "Long"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Flags", "name": "badge", "type": "boolean", "value": false}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Flags", "name": "label", "type": "string", "value": "Hi"}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "Hello there"}));
|
||||
// Types must fit, and only instances take them.
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "label"})).is_err());
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Title", "variable": "badge"})).is_err());
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Nope", "variable": "badge"})).is_err());
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [c], "field": "componentProperties/Show dot", "variable": "badge"})).is_err());
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Title", "variable": "label"}));
|
||||
assert!(!node(&s, idot).visible, "the bound boolean hides the dot");
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Hi");
|
||||
// Values and modes flow in.
|
||||
ex(&mut s, "variable.set", json!({"variable": "badge", "value": true}));
|
||||
assert!(node(&s, idot).visible);
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Flags", "mode": "Long", "ids": [i]}));
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Hello there");
|
||||
assert!(node(&s, ititle).overrides.is_empty(), "property values aren't overrides");
|
||||
// A main edit rebuilds the instance: its bindings stay.
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00FF00"}}));
|
||||
assert!(node(&s, i).bound.contains_key("componentProperties/Title"));
|
||||
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "After edit"}));
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "After edit");
|
||||
// Renaming the property keeps the binding; deleting it drops the binding.
|
||||
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Title", "newName": "Heading"}));
|
||||
assert!(node(&s, i).bound.contains_key("componentProperties/Heading"));
|
||||
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "Renamed"}));
|
||||
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Renamed");
|
||||
ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||||
assert!(!node(&s, i).bound.keys().any(|k| k.contains("Heading")));
|
||||
assert!(matches!(node(&s, i).kind, NodeKind::Instance { .. }));
|
||||
// Unbinding keeps the value (the Long mode's `false`).
|
||||
assert!(!node(&s, idot).visible);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": null}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "badge", "mode": "Long", "value": true}));
|
||||
assert!(!node(&s, idot).visible);
|
||||
ex(&mut s, "variable.set", json!({"variable": "badge", "mode": "Long", "value": false}));
|
||||
// Resetting the instance drops its property bindings with its values; detaching too.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
|
||||
assert!(!node(&s, idot).visible);
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
|
||||
assert!(node(&s, i).bound.is_empty());
|
||||
assert!(node(&s, idot).visible, "back to the default");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
assert!(node(&s, i).bound.is_empty());
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
use egui::{Id, Sense, Ui, vec2};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_engine::components::{
|
||||
inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_options, variant_props, variant_set,
|
||||
PROP_BINDING, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_options, variant_props, variant_set,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -618,20 +618,26 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
|
|||
row(ui, 32.0, |ui| {
|
||||
prop_label(ui, &k, type_icon(&d));
|
||||
let w = ui.available_width().max(60.0);
|
||||
// Boolean and text values can come from a boolean or string variable.
|
||||
let bind_field = format!("{PROP_BINDING}{k}");
|
||||
match d {
|
||||
PropDef::Boolean { .. } => {
|
||||
let on = v.as_bool().unwrap_or(false);
|
||||
if widgets::checkbox(ui, on, "") {
|
||||
app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): !on}}));
|
||||
}
|
||||
crate::variable_ui::bindable(app, ui, &[id], &bind_field, VariableType::Boolean, w, |app, ui| {
|
||||
let on = v.as_bool().unwrap_or(false);
|
||||
if widgets::checkbox(ui, on, "") {
|
||||
app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): !on}}));
|
||||
}
|
||||
});
|
||||
}
|
||||
PropDef::Text { .. } => {
|
||||
let cur = v.as_str().unwrap_or_default().to_string();
|
||||
if let Some(ns) = widgets::text_field(ui, Id::new(("iprop", id, k.as_str())), &cur, w, "")
|
||||
&& ns != cur
|
||||
{
|
||||
app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): ns}}));
|
||||
}
|
||||
crate::variable_ui::bindable(app, ui, &[id], &bind_field, VariableType::String, w, |app, ui| {
|
||||
let cur = v.as_str().unwrap_or_default().to_string();
|
||||
if let Some(ns) = widgets::text_field(ui, Id::new(("iprop", id, k.as_str())), &cur, w, "")
|
||||
&& ns != cur
|
||||
{
|
||||
app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): ns}}));
|
||||
}
|
||||
});
|
||||
}
|
||||
PropDef::InstanceSwap { .. } => {
|
||||
let cur = figcraft_engine::params::id_of(&v);
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ icons! {
|
|||
ViewGrid,
|
||||
/// Add an emoji reaction: a smiling face.
|
||||
Reaction,
|
||||
/// Drag to reorder: two columns of three dots.
|
||||
DragHandle,
|
||||
}
|
||||
|
||||
/// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise).
|
||||
|
|
@ -955,6 +957,12 @@ fn draw(p: &P, icon: Icon) {
|
|||
p.dot(10.0, 6.5, 0.9);
|
||||
p.arc(8.0, 8.5, 3.0, 0.6, PI - 0.6);
|
||||
}
|
||||
DragHandle => {
|
||||
for y in [4.0, 8.0, 12.0] {
|
||||
p.dot(6.0, y, 1.1);
|
||||
p.dot(10.0, y, 1.1);
|
||||
}
|
||||
}
|
||||
ViewGrid => {
|
||||
for (x, y) in [(2.5, 2.5), (9.5, 2.5), (2.5, 9.5), (9.5, 9.5)] {
|
||||
p.rect(x, y, x + 4.0, y + 4.0, 1.0);
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
|||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
} else {
|
||||
ui.painter().line_segment([pos2(x0, y), pos2(rr.max.x, y)], Stroke::new(2.0, ACCENT));
|
||||
crate::widgets::insertion_line(ui.painter(), x0, rr.max.x, y);
|
||||
}
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
drop = Some((row.id, zone));
|
||||
|
|
@ -741,12 +741,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
|||
1 => doc.node(dg).map(|n| n.name.clone()).unwrap_or_default(),
|
||||
n => format!("{n} layers"),
|
||||
};
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("layer_drag_ghost")));
|
||||
let galley = crate::bidi::name_galley(&painter, &label, regular(11.0), t.text, 240.0);
|
||||
let r = Rect::from_min_size(p + vec2(14.0, 10.0), galley.size() + vec2(16.0, 8.0));
|
||||
painter.rect_filled(r, CornerRadius::same(5), t.panel);
|
||||
painter.rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside);
|
||||
painter.galley(r.min + vec2(8.0, 4.0), galley, t.text);
|
||||
let galley = crate::bidi::name_galley(ui.painter(), &label, regular(11.0), t.text, 240.0);
|
||||
crate::widgets::drag_ghost(ui.ctx(), p, galley);
|
||||
}
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
ui.data_mut(|d| {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub mod home;
|
|||
pub mod icons;
|
||||
pub mod layers;
|
||||
pub mod layout_panel;
|
||||
pub mod list_drag;
|
||||
pub mod measure_overlay;
|
||||
pub mod menus;
|
||||
pub mod present;
|
||||
|
|
@ -52,6 +53,8 @@ mod layers_drag_tests;
|
|||
#[cfg(test)]
|
||||
mod layers_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod perf_tests;
|
||||
#[cfg(test)]
|
||||
mod present_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
//! Drag to reorder the Design panel's lists (fills, strokes, effects, layout grids). A row is
|
||||
//! dragged by its grip, the empty strip left of its fields (a handle shows over the row); while
|
||||
//! dragging, a line shows where it will land and its label follows the pointer, as for layers
|
||||
//! dragged in the layers panel; dropping moves it with one command (one undo step). The panel
|
||||
//! shows the topmost item first while the model keeps lists bottom first.
|
||||
|
||||
use egui::{Id, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, regular};
|
||||
use crate::widgets::{self, PAD};
|
||||
|
||||
/// The row of list `list` being dragged (its model index; temp data).
|
||||
fn drag_id(list: &str) -> Id {
|
||||
Id::new(("list_drag", list))
|
||||
}
|
||||
|
||||
/// The grip of item `index` of list `list` (tests find rows by it).
|
||||
pub(crate) fn grip_id(list: &str, index: usize) -> Id {
|
||||
Id::new(("list_grip", list, index))
|
||||
}
|
||||
|
||||
/// One list's rows in one frame: lay out each row, register it with [`ListDrag::row`], then call
|
||||
/// [`ListDrag::finish`].
|
||||
pub struct ListDrag {
|
||||
list: &'static str,
|
||||
/// The item being dragged (model index).
|
||||
dragging: Option<usize>,
|
||||
/// The rows as laid out, top to bottom: model index and the area the row covers (with any
|
||||
/// settings open under it).
|
||||
rows: Vec<(usize, Rect)>,
|
||||
/// What the pointer carries.
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
impl ListDrag {
|
||||
pub fn new(ui: &Ui, list: &'static str) -> Self {
|
||||
let id = drag_id(list);
|
||||
let mut dragging: Option<usize> = ui.data(|d| d.get_temp(id));
|
||||
// Escape cancels; a drag whose release this list never saw (it wasn't shown) is over.
|
||||
if dragging.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape) || !(i.pointer.primary_down() || i.pointer.any_released())) {
|
||||
ui.data_mut(|d| d.remove::<usize>(id));
|
||||
dragging = None;
|
||||
}
|
||||
Self { list, dragging, rows: Vec::new(), label: None }
|
||||
}
|
||||
|
||||
/// Item `index` (model order) was laid out with its main row at `header`, covering `extent`
|
||||
/// (the header plus anything open under it). Its grip starts a drag carrying `label`.
|
||||
pub fn row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) {
|
||||
let grip = Rect::from_min_max(header.min, pos2(header.min.x + PAD, header.max.y));
|
||||
let resp = ui.interact(grip, grip_id(self.list, index), Sense::drag());
|
||||
// The handle shows while the pointer is over the row (or the row is being dragged).
|
||||
let shown = match self.dragging {
|
||||
Some(d) => d == index,
|
||||
None => ui.rect_contains_pointer(header),
|
||||
};
|
||||
if shown {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
icons::paint(ui.painter(), Rect::from_center_size(grip.center(), vec2(12.0, 12.0)), Icon::DragHandle, t.icon_secondary);
|
||||
}
|
||||
if resp.hovered() && self.dragging.is_none() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id(self.list), index));
|
||||
self.dragging = Some(index);
|
||||
}
|
||||
if self.dragging == Some(index) {
|
||||
self.label = Some(label());
|
||||
}
|
||||
self.rows.push((index, extent));
|
||||
}
|
||||
|
||||
/// After the rows: while dragging, the insertion line and the dragged label; on release over
|
||||
/// the list, the move as `(from, to)` model indices (for `fill.move` and friends).
|
||||
pub fn finish(self, ui: &mut Ui) -> Option<(usize, usize)> {
|
||||
let from = self.dragging?;
|
||||
let (pos, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released()));
|
||||
let target = pos.and_then(|p| drop_slot(&self.rows, from, p));
|
||||
if let Some((_, y)) = target
|
||||
&& let Some(span) = self.span()
|
||||
{
|
||||
widgets::insertion_line(ui.painter(), span.min.x + PAD, span.max.x - PAD, y);
|
||||
}
|
||||
if let (Some(p), Some(label)) = (pos, self.label) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = ui.painter().layout_no_wrap(label, regular(11.0), t.text);
|
||||
widgets::drag_ghost(ui.ctx(), p, galley);
|
||||
}
|
||||
if !released {
|
||||
return None;
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<usize>(drag_id(self.list)));
|
||||
target.map(|(to, _)| (from, to))
|
||||
}
|
||||
|
||||
/// The area all rows cover.
|
||||
fn span(&self) -> Option<Rect> {
|
||||
self.rows.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where item `from` lands when dropped at `p` over `rows` (top to bottom, topmost item first):
|
||||
/// its new model index and the insertion line's y. None outside the list (half a row of slack
|
||||
/// above and below) or where it would stay put.
|
||||
fn drop_slot(rows: &[(usize, Rect)], from: usize, p: egui::Pos2) -> Option<(usize, f32)> {
|
||||
let (first, last) = (rows.first()?.1, rows.last()?.1);
|
||||
let slack = first.height().min(last.height()) / 2.0;
|
||||
let area = Rect::from_min_max(pos2(first.min.x.min(last.min.x), first.min.y - slack), pos2(first.max.x.max(last.max.x), last.max.y + slack));
|
||||
if !area.contains(p) {
|
||||
return None;
|
||||
}
|
||||
let n = rows.len();
|
||||
let shown = rows.iter().position(|(i, _)| *i == from)?;
|
||||
// The gap under the pointer: how many rows sit above it.
|
||||
let gap = rows.iter().filter(|(_, r)| r.center().y < p.y).count();
|
||||
if gap == shown || gap == shown + 1 {
|
||||
return None;
|
||||
}
|
||||
let y = match (gap.checked_sub(1).and_then(|g| rows.get(g)), rows.get(gap)) {
|
||||
(Some((_, above)), Some((_, below))) => (above.max.y + below.min.y) / 2.0,
|
||||
(None, _) => first.min.y,
|
||||
(_, None) => last.max.y,
|
||||
};
|
||||
// Shown position once moved, then its model index (shown in reverse).
|
||||
let at = if gap > shown { gap - 1 } else { gap };
|
||||
Some((n - 1 - at, y))
|
||||
}
|
||||
|
||||
/// Keep per-row flags (`Id::new((salt, index))`, e.g. an open settings section) with their rows
|
||||
/// when item `from` of a `len`-long list moves to `to`.
|
||||
pub fn move_flags(ui: &Ui, salt: &'static str, len: usize, from: usize, to: usize) {
|
||||
ui.data_mut(|d| {
|
||||
let flags: Vec<Option<bool>> = (0..len).map(|i| d.get_temp::<bool>(Id::new((salt, i)))).collect();
|
||||
for (i, f) in flags.into_iter().enumerate() {
|
||||
let j = figcraft_engine::cmd::lists::moved_index(i, from, to);
|
||||
match f {
|
||||
Some(v) => {
|
||||
d.insert_temp(Id::new((salt, j)), v);
|
||||
}
|
||||
None => d.remove::<bool>(Id::new((salt, j))),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rows(n: usize) -> Vec<(usize, Rect)> {
|
||||
// Shown topmost first: model index n-1 at the top.
|
||||
(0..n).map(|k| (n - 1 - k, Rect::from_min_size(pos2(0.0, k as f32 * 32.0), vec2(240.0, 32.0)))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_slots_map_to_model_indices() {
|
||||
let r = rows(3);
|
||||
// Drag the bottom item (model 0, shown last) above the top one: it becomes the top (2).
|
||||
assert_eq!(drop_slot(&r, 0, pos2(100.0, 4.0)), Some((2, 0.0)));
|
||||
// Between the first and second rows: model 1.
|
||||
assert_eq!(drop_slot(&r, 0, pos2(100.0, 40.0)), Some((1, 32.0)));
|
||||
// Around itself: no move.
|
||||
assert_eq!(drop_slot(&r, 0, pos2(100.0, 70.0)), None);
|
||||
assert_eq!(drop_slot(&r, 0, pos2(100.0, 95.0)), None);
|
||||
// The top item to the bottom: model 0.
|
||||
assert_eq!(drop_slot(&r, 2, pos2(100.0, 95.0)), Some((0, 96.0)));
|
||||
// Outside the list, or an item it doesn't show.
|
||||
assert_eq!(drop_slot(&r, 2, pos2(100.0, 200.0)), None);
|
||||
assert_eq!(drop_slot(&r, 2, pos2(400.0, 95.0)), None);
|
||||
assert_eq!(drop_slot(&r, 7, pos2(100.0, 4.0)), None);
|
||||
assert_eq!(drop_slot(&[], 0, pos2(0.0, 0.0)), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
//! Dragging fill, effect and layout grid rows in the Design panel, driven through egui headless:
|
||||
//! a drop reorders the list as one undo step, Escape cancels, and lists that differ across the
|
||||
//! selection ("Mixed") have nothing to drag.
|
||||
|
||||
use egui::{Event, Pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::list_drag::grip_id;
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A selected rectangle with three solid fills (bottom to top: red, green, blue).
|
||||
fn setup() -> (Ui, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
|
||||
s.execute("fill.add", &json!({"ids": [r], "color": "#00FF00"})).unwrap();
|
||||
s.execute("fill.add", &json!({"ids": [r], "color": "#0000FF"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
(Ui::new(s), r)
|
||||
}
|
||||
|
||||
fn fills(u: &Ui, r: NodeId) -> Vec<String> {
|
||||
u.app.session.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
/// The middle of item `index`'s grip in list `list` (as laid out last frame).
|
||||
fn grip(u: &Ui, list: &str, index: usize) -> Option<Pos2> {
|
||||
u.ctx.read_response(grip_id(list, index)).map(|r| r.rect.center())
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` in small steps, and leave the button down.
|
||||
fn drag(u: &mut Ui, from: Pos2, to: Pos2) {
|
||||
u.button(from, true);
|
||||
for k in 1..=10 {
|
||||
u.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_fill_row_reorders_the_fills_in_one_step() {
|
||||
let (mut u, r) = setup();
|
||||
let (bottom, top) = (grip(&u, "fills", 0).unwrap(), grip(&u, "fills", 2).unwrap());
|
||||
assert!(top.y < bottom.y, "the topmost paint is listed first");
|
||||
let undo = u.app.session.ds().unwrap().history.undo.len();
|
||||
// The bottom paint (red) above the top row: it becomes the topmost.
|
||||
drag(&mut u, bottom, top - egui::vec2(0.0, 12.0));
|
||||
assert_eq!(fills(&u, r), ["FF0000", "00FF00", "0000FF"], "nothing moves before the drop");
|
||||
u.button(top - egui::vec2(0.0, 12.0), false);
|
||||
assert_eq!(fills(&u, r), ["00FF00", "0000FF", "FF0000"]);
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
// Dropping a row where it already is changes nothing.
|
||||
let g = grip(&u, "fills", 1).unwrap();
|
||||
drag(&mut u, g, g + egui::vec2(0.0, 6.0));
|
||||
u.button(g + egui::vec2(0.0, 6.0), false);
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_row_drag() {
|
||||
let (mut u, r) = setup();
|
||||
let (bottom, top) = (grip(&u, "fills", 0).unwrap(), grip(&u, "fills", 2).unwrap());
|
||||
drag(&mut u, bottom, top);
|
||||
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
|
||||
u.button(top, false);
|
||||
assert_eq!(fills(&u, r), ["FF0000", "00FF00", "0000FF"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_and_grids_drag_too_and_mixed_lists_dont() {
|
||||
let (mut u, r) = setup();
|
||||
let f = id(u.app.session.execute("frame.create", &json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
for p in [json!({"type": "dropShadow"}), json!({"type": "layerBlur"})] {
|
||||
u.app.session.execute("effect.add", &json!({"ids": [f], "type": p["type"]})).unwrap();
|
||||
}
|
||||
u.app.session.execute("grid.add", &json!({"ids": [f], "grid": {"kind": "columns"}})).unwrap();
|
||||
u.app.session.execute("grid.add", &json!({"ids": [f], "grid": {"kind": "rows"}})).unwrap();
|
||||
u.app.session.execute("select.set", &json!({"ids": [f]})).unwrap();
|
||||
for _ in 0..2 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let (a, b) = (grip(&u, "effects", 0).unwrap(), grip(&u, "effects", 1).unwrap());
|
||||
drag(&mut u, a, b - egui::vec2(0.0, 12.0));
|
||||
u.button(b - egui::vec2(0.0, 12.0), false);
|
||||
let kinds: Vec<&str> = u.app.session.doc().unwrap().node(f).unwrap().effects.iter().map(|e| e.label()).collect();
|
||||
assert_eq!(kinds, ["Layer blur", "Drop shadow"]);
|
||||
// The layout grid section is below the fold: scroll the panel.
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -400.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
for _ in 0..4 {
|
||||
u.frame(vec![Event::PointerMoved(Pos2::new(1300.0, 500.0)), wheel.clone()]);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
let (a, b) = (grip(&u, "grids", 0).unwrap(), grip(&u, "grids", 1).unwrap());
|
||||
assert!(b.y < 880.0, "{b:?}");
|
||||
drag(&mut u, a, b - egui::vec2(0.0, 12.0));
|
||||
u.button(b - egui::vec2(0.0, 12.0), false);
|
||||
let grids: Vec<figcraft_doc::GridKind> = u.app.session.doc().unwrap().node(f).unwrap().grids.iter().map(|g| g.kind).collect();
|
||||
assert_eq!(grids, [figcraft_doc::GridKind::Rows, figcraft_doc::GridKind::Columns]);
|
||||
// The frame and the rectangle have different fills: nothing to drag.
|
||||
u.app.session.execute("select.set", &json!({"ids": [r, f]})).unwrap();
|
||||
for _ in 0..2 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(grip(&u, "fills", 0).is_none());
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_color::{BlendMode, Paint, PaintKind};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_engine::cmd::lists::moved_index;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::list_drag::{ListDrag, move_flags};
|
||||
use crate::style_picker::{self, StyleSlot};
|
||||
use crate::theme::{ACCENT, Tokens, regular, semibold};
|
||||
use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD};
|
||||
|
|
@ -218,18 +220,24 @@ pub fn menu_check(ui: &mut Ui, on: bool, label: &str, shortcut: &str) -> bool {
|
|||
}
|
||||
|
||||
pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) {
|
||||
block(ui, h, (h - FIELD_H) / 2.0, add);
|
||||
row_rect(ui, h, add);
|
||||
}
|
||||
|
||||
/// [`row`], returning the area the row takes.
|
||||
pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect {
|
||||
block(ui, h, (h - FIELD_H) / 2.0, add)
|
||||
}
|
||||
|
||||
/// A panel row `h` tall whose content is inset `inset_y` from its top and bottom (for content
|
||||
/// taller than one field).
|
||||
pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) {
|
||||
/// taller than one field); returns the area the row takes.
|
||||
pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect {
|
||||
let w = ui.available_width();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
|
||||
let mut child =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, inset_y))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
child.spacing_mut().item_spacing = vec2(8.0, 0.0);
|
||||
add(&mut child);
|
||||
r
|
||||
}
|
||||
|
||||
fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
|
|
@ -859,9 +867,20 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
|
|||
match &list {
|
||||
None => row(ui, 32.0, |ui| widgets::label(ui, "Click + to replace mixed content", t.text_secondary)),
|
||||
Some(l) => {
|
||||
// Top of the list paints on top: show in reverse like Figma.
|
||||
// The last paint is on top: shown first. Rows drag to reorder.
|
||||
let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" });
|
||||
for (i, p) in l.iter().enumerate().rev() {
|
||||
paint_row(app, ui, &ids, i, p, fills);
|
||||
let r = paint_row(app, ui, &ids, i, p, fills);
|
||||
drag.row(ui, i, r, r, || paint_label(p));
|
||||
}
|
||||
if let Some((from, to)) = drag.finish(ui) {
|
||||
app.run(if fills { "fill.move" } else { "stroke.move" }, json!({"ids": ids, "from": from, "to": to}));
|
||||
// An open colour picker keeps editing the paint it was on.
|
||||
if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
|
||||
&& *f == fills
|
||||
{
|
||||
*index = moved_index(*index, from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1136,11 +1155,12 @@ fn cap_art(p: &egui::Painter, r: Rect, col: Color32, cap: StrokeCap, start: bool
|
|||
}
|
||||
}
|
||||
|
||||
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) {
|
||||
/// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area.
|
||||
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) -> Rect {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ids = ids.to_vec();
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
row(ui, 32.0, |ui| {
|
||||
row_rect(ui, 32.0, |ui| {
|
||||
let w = ui.available_width() - 56.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
|
||||
|
|
@ -1152,12 +1172,14 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
|||
}
|
||||
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
|
||||
let bound = (i == 0)
|
||||
.then(|| app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, &ids, if fills { "fill" } else { "stroke" })))
|
||||
.flatten();
|
||||
// The variable bound to this paint's colour (its key on the layers: `fill` or `fills/<i>/color`).
|
||||
let bound = app.session.doc().ok().and_then(|d| {
|
||||
let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?;
|
||||
Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?))
|
||||
});
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } if bound.is_some() => {
|
||||
let name = bound.clone().unwrap_or_default();
|
||||
let (key, name) = bound.clone().unwrap_or_default();
|
||||
let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0));
|
||||
child.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
|
||||
child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
|
|
@ -1169,12 +1191,12 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
|||
regular(11.0),
|
||||
t.text,
|
||||
);
|
||||
let _ = ui.interact(chip, Id::new(("bound_paint", fills)), Sense::hover()).on_hover_text(&name);
|
||||
let _ = ui.interact(chip, Id::new(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name);
|
||||
let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let dresp = ui.interact(dr, Id::new(("detach_paint", fills)), Sense::click()).on_hover_text("Detach variable");
|
||||
let dresp = ui.interact(dr, Id::new(("detach_paint", fills, i)), Sense::click()).on_hover_text("Detach variable");
|
||||
icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary);
|
||||
if dresp.clicked() {
|
||||
app.run("variable.bind", json!({"ids": ids, "field": if fills { "fill" } else { "stroke" }, "variable": null}));
|
||||
app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null}));
|
||||
}
|
||||
}
|
||||
PaintKind::Solid { color } => {
|
||||
|
|
@ -1185,12 +1207,8 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
|||
app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()}));
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let label = match other {
|
||||
PaintKind::Gradient(g) => format!("{:?}", g.kind),
|
||||
_ => "Image".into(),
|
||||
};
|
||||
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text);
|
||||
_ => {
|
||||
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text);
|
||||
}
|
||||
}
|
||||
let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max);
|
||||
|
|
@ -1217,11 +1235,19 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
|||
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
|
||||
app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i}));
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// A paint row's label (also what a drag of it carries): its colour, gradient kind or "Image".
|
||||
fn paint_label(p: &Paint) -> String {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => color.to_hex(),
|
||||
PaintKind::Gradient(g) => format!("{:?}", g.kind),
|
||||
PaintKind::Image(_) => "Image".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ids = sel.to_vec();
|
||||
let list = common(doc, sel, |n| n.effects.clone());
|
||||
let empty = list.as_ref().is_none_or(|l| l.is_empty());
|
||||
|
|
@ -1234,9 +1260,10 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
}
|
||||
style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect);
|
||||
let Some(list) = list else { return };
|
||||
let mut drag = ListDrag::new(ui, "effects");
|
||||
for (i, e) in list.iter().enumerate().rev() {
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(Id::new(("fx_open", i)))).unwrap_or(false);
|
||||
row(ui, 32.0, |ui| {
|
||||
let header = row_rect(ui, 32.0, |ui| {
|
||||
if icons::button(ui, Icon::Effects, 24.0, open, "Effect settings").clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(Id::new(("fx_open", i)), open));
|
||||
|
|
@ -1265,8 +1292,15 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
if open {
|
||||
effect_settings(app, ui, &ids, i, e);
|
||||
}
|
||||
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || e.label().to_string());
|
||||
}
|
||||
if let Some((from, to)) = drag.finish(ui) {
|
||||
app.run("effect.move", json!({"ids": ids, "from": from, "to": to}));
|
||||
move_flags(ui, "fx_open", list.len(), from, to);
|
||||
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
|
||||
*index = moved_index(*index, from, to);
|
||||
}
|
||||
}
|
||||
let _ = t;
|
||||
}
|
||||
|
||||
/// A number field of effect `i` that can be bound to a variable (`effects/<i>/<prop>`).
|
||||
|
|
@ -1322,16 +1356,10 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
|
|||
});
|
||||
// The colour: a bound variable's chip, else swatch, hex and alpha with a variable button.
|
||||
let color_field = format!("effects/{i}/color");
|
||||
let bound = app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, ids, &color_field)).is_some();
|
||||
row(ui, 32.0, |ui| {
|
||||
let row_rect = Rect::from_min_size(ui.cursor().min, vec2(half * 2.0 + 8.0, FIELD_H));
|
||||
if bound {
|
||||
let (r, _) = ui.allocate_exact_size(row_rect.size(), Sense::hover());
|
||||
crate::variable_ui::bound_chip(app, ui, ids, &color_field, VariableType::Color, r);
|
||||
return;
|
||||
}
|
||||
fx_color(app, ui, ids, i, e, false, &edit_color);
|
||||
crate::variable_ui::bind_button(ui, row_rect, ids, &color_field, VariableType::Color);
|
||||
crate::variable_ui::bindable(app, ui, ids, &color_field, VariableType::Color, half * 2.0 + 8.0, |app, ui| {
|
||||
fx_color(app, ui, ids, i, e, false, &edit_color)
|
||||
});
|
||||
});
|
||||
if !inner {
|
||||
row(ui, 32.0, |ui| {
|
||||
|
|
@ -1498,43 +1526,152 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
}
|
||||
style_picker::chip(app, ui, doc, &ids, StyleSlot::Grid);
|
||||
let Some(list) = list else { return };
|
||||
for (i, g) in list.iter().enumerate() {
|
||||
row(ui, 32.0, |ui| {
|
||||
let label = match g.kind {
|
||||
GridKind::Grid => format!("Grid ({})", g.size),
|
||||
GridKind::Columns => format!("{} columns", g.count),
|
||||
GridKind::Rows => format!("{} rows", g.count),
|
||||
};
|
||||
// The last grid first, like paints and effects; rows drag to reorder.
|
||||
let mut drag = ListDrag::new(ui, "grids");
|
||||
for (i, g) in list.iter().enumerate().rev() {
|
||||
let open_id = Id::new(("grid_open", i));
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
|
||||
let header = row_rect(ui, 32.0, |ui| {
|
||||
if icons::button(ui, Icon::Grid, 24.0, open, "Layout grid settings").clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
let w = ui.available_width() - 56.0;
|
||||
let resp = widgets::dropdown(ui, &label, w, Some(Icon::Grid));
|
||||
let resp = widgets::dropdown(ui, &grid_label(g), w, None);
|
||||
egui::Popup::menu(&resp).width(220.0).show(|ui| {
|
||||
for (l, kind) in [("Grid", GridKind::Grid), ("Columns", GridKind::Columns), ("Rows", GridKind::Rows)] {
|
||||
if menu_item(ui, l, "") {
|
||||
let mut gs = list.clone();
|
||||
if let Some(slot) = gs.get_mut(i) {
|
||||
*slot = match kind {
|
||||
GridKind::Grid => LayoutGrid::default(),
|
||||
GridKind::Columns => LayoutGrid::columns(),
|
||||
GridKind::Rows => LayoutGrid::rows(),
|
||||
};
|
||||
}
|
||||
app.set_props(&ids, json!({"grids": gs}));
|
||||
let fresh = match kind {
|
||||
GridKind::Grid => LayoutGrid::default(),
|
||||
GridKind::Columns => LayoutGrid::columns(),
|
||||
GridKind::Rows => LayoutGrid::rows(),
|
||||
};
|
||||
app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| *x = fresh)}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle").clicked() {
|
||||
let mut gs = list.clone();
|
||||
if let Some(slot) = gs.get_mut(i) {
|
||||
slot.visible = !slot.visible;
|
||||
}
|
||||
app.set_props(&ids, json!({"grids": gs}));
|
||||
app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| x.visible = !x.visible)}));
|
||||
}
|
||||
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
|
||||
app.run("grid.remove", json!({"ids": ids, "index": i}));
|
||||
}
|
||||
});
|
||||
if open {
|
||||
grid_settings(app, ui, &ids, &list, i, g);
|
||||
}
|
||||
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || grid_label(g));
|
||||
}
|
||||
if let Some((from, to)) = drag.finish(ui) {
|
||||
app.run("grid.move", json!({"ids": ids, "from": from, "to": to}));
|
||||
move_flags(ui, "grid_open", list.len(), from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/// A layout grid's name in its row ("Grid (10)", "5 columns").
|
||||
fn grid_label(g: &LayoutGrid) -> String {
|
||||
match g.kind {
|
||||
GridKind::Grid => format!("Grid ({})", g.size),
|
||||
GridKind::Columns => format!("{} columns", g.count),
|
||||
GridKind::Rows => format!("{} rows", g.count),
|
||||
}
|
||||
}
|
||||
|
||||
/// `list` with grid `i` changed by `f`.
|
||||
fn grids_with(list: &[LayoutGrid], i: usize, f: impl FnOnce(&mut LayoutGrid)) -> Vec<LayoutGrid> {
|
||||
let mut gs = list.to_vec();
|
||||
if let Some(g) = gs.get_mut(i) {
|
||||
f(g);
|
||||
}
|
||||
gs
|
||||
}
|
||||
|
||||
/// Layout grid `i`'s settings: size (grids); count, type, width or height, margin or offset and
|
||||
/// gutter (columns and rows); colour. The numbers can be bound to number variables
|
||||
/// (`layoutGrids/<i>/<size|count|gutter|offset>`).
|
||||
fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGrid], i: usize, g: &LayoutGrid) {
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
let set = |f: &dyn Fn(&mut LayoutGrid)| json!({"ids": ids, "props": {"grids": grids_with(list, i, f)}});
|
||||
// One bindable number field, applied with `apply` (a scrub is one undo step).
|
||||
let field = |app: &mut FigApp, ui: &mut Ui, name: &str, value: f64, apply: &dyn Fn(&mut LayoutGrid, f64)| {
|
||||
let key = format!("layoutGrids/{i}/{name}");
|
||||
let e = crate::variable_ui::number_field(app, ui, ids, &key, Id::new(("grid_field", name, i)), Lead::None, Some(value), "", col);
|
||||
app.field_run(e, "node.set", |v| set(&|x| apply(x, v)));
|
||||
};
|
||||
let size = |x: &mut LayoutGrid, v: f64| x.size = v.max(1.0);
|
||||
let offset = |x: &mut LayoutGrid, v: f64| x.offset = v.max(0.0);
|
||||
let gutter = |x: &mut LayoutGrid, v: f64| x.gutter = v.max(0.0);
|
||||
if g.kind == GridKind::Grid {
|
||||
widgets::sublabels(ui, "Size", None);
|
||||
row(ui, 32.0, |ui| field(app, ui, "size", g.size, &size));
|
||||
} else {
|
||||
let cols = g.kind == GridKind::Columns;
|
||||
widgets::sublabels(ui, "Count", Some("Type"));
|
||||
row(ui, 32.0, |ui| {
|
||||
field(app, ui, "count", f64::from(g.count), &|x, v| x.count = v.round().clamp(1.0, 1000.0) as u32);
|
||||
let names = if cols { ["Stretch", "Left", "Center", "Right"] } else { ["Stretch", "Top", "Center", "Bottom"] };
|
||||
let aligns = [GridAlign::Stretch, GridAlign::Min, GridAlign::Center, GridAlign::Max];
|
||||
let cur = aligns.iter().position(|a| *a == g.align).unwrap_or(0);
|
||||
let resp = widgets::dropdown(ui, names.get(cur).copied().unwrap_or_default(), col, None);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for (k, (name, a)) in names.iter().zip(aligns).enumerate() {
|
||||
if menu_check(ui, k == cur, name, "") {
|
||||
// Fixed-size columns need a size of their own.
|
||||
app.run(
|
||||
"node.set",
|
||||
set(&|x| {
|
||||
x.align = a;
|
||||
if a != GridAlign::Stretch && !(x.size.is_finite() && x.size > 0.0) {
|
||||
x.size = 60.0;
|
||||
}
|
||||
}),
|
||||
);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let span = if cols { "Width" } else { "Height" };
|
||||
match g.align {
|
||||
GridAlign::Stretch => {
|
||||
widgets::sublabels(ui, "Margin", Some("Gutter"));
|
||||
row(ui, 32.0, |ui| {
|
||||
field(app, ui, "offset", g.offset, &offset);
|
||||
field(app, ui, "gutter", g.gutter, &gutter);
|
||||
});
|
||||
}
|
||||
GridAlign::Center => {
|
||||
widgets::sublabels(ui, span, Some("Gutter"));
|
||||
row(ui, 32.0, |ui| {
|
||||
field(app, ui, "size", g.size, &size);
|
||||
field(app, ui, "gutter", g.gutter, &gutter);
|
||||
});
|
||||
}
|
||||
GridAlign::Min | GridAlign::Max => {
|
||||
widgets::sublabels(ui, span, Some("Offset"));
|
||||
row(ui, 32.0, |ui| {
|
||||
field(app, ui, "size", g.size, &size);
|
||||
field(app, ui, "offset", g.offset, &offset);
|
||||
});
|
||||
widgets::sublabels(ui, "Gutter", None);
|
||||
row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The colour: swatch, hex and alpha.
|
||||
widgets::sublabels(ui, "Color", None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let (sw, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
widgets::paint_swatch(ui.painter(), sw, &Paint::solid(g.color));
|
||||
if let Some(h) = widgets::text_field(ui, Id::new(("grid_hex", i)), &g.color.to_hex(), 80.0, "")
|
||||
&& let Some(c) = figcraft_color::Color::from_hex(&h)
|
||||
{
|
||||
app.run("node.set", set(&|x| x.color = c.with_alpha(x.color.a)));
|
||||
}
|
||||
let e = widgets::number_field(ui, Id::new(("grid_alpha", i)), Lead::None, Some((f64::from(g.color.a) * 100.0).round()), "%", 56.0, 1.0);
|
||||
app.field_run(e, "node.set", |v| set(&|x| x.color = x.color.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32)));
|
||||
});
|
||||
}
|
||||
|
||||
/// One group of inspected properties: label, value (with a swatch for colours) and the style or
|
||||
|
|
|
|||
|
|
@ -224,11 +224,33 @@ pub fn number_field(
|
|||
/// otherwise nothing and false.
|
||||
pub fn bound_chip(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kind: VariableType, rect: Rect) -> bool {
|
||||
let Some(name) = app.session.doc().ok().and_then(|d| bound_name(d, ids, field)) else { return false };
|
||||
let ev = widgets::bound_field(ui, Id::new(("bound_chip", field)), Lead::None, rect, &name);
|
||||
let ev = widgets::bound_field(ui, Id::new(("bound_chip", field, ids)), Lead::None, rect, &name);
|
||||
handle(app, ui, ids, field, kind, ev);
|
||||
true
|
||||
}
|
||||
|
||||
/// A control whose value can come from a variable: while `field` of `ids` is bound, the
|
||||
/// variable's chip (`width` wide) stands in for it; otherwise `control` lays it out in the current
|
||||
/// row and a variable button shows over that area on hover.
|
||||
pub fn bindable(
|
||||
app: &mut FigApp,
|
||||
ui: &mut Ui,
|
||||
ids: &[NodeId],
|
||||
field: &str,
|
||||
kind: VariableType,
|
||||
width: f32,
|
||||
control: impl FnOnce(&mut FigApp, &mut Ui),
|
||||
) {
|
||||
let area = Rect::from_min_size(ui.cursor().min, vec2(width, widgets::FIELD_H));
|
||||
if app.session.doc().ok().and_then(|d| bound_name(d, ids, field)).is_some() {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
bound_chip(app, ui, ids, field, kind, r);
|
||||
return;
|
||||
}
|
||||
control(app, ui);
|
||||
bind_button(ui, area, ids, field, kind);
|
||||
}
|
||||
|
||||
/// A variable button at the right end of `rect` while the pointer is over it: opens the picker
|
||||
/// to bind `field` of `ids`.
|
||||
pub fn bind_button(ui: &mut Ui, rect: Rect, ids: &[NodeId], field: &str, kind: VariableType) {
|
||||
|
|
@ -237,7 +259,7 @@ pub fn bind_button(ui: &mut Ui, rect: Rect, ids: &[NodeId], field: &str, kind: V
|
|||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let b = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(18.0, 18.0));
|
||||
let resp = ui.interact(b, Id::new(("bind_button", field)), Sense::click()).on_hover_text("Apply variable");
|
||||
let resp = ui.interact(b, Id::new(("bind_button", field, ids)), Sense::click()).on_hover_text("Apply variable");
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(b, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -510,6 +510,23 @@ pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: b
|
|||
out
|
||||
}
|
||||
|
||||
/// Where a dragged row will land: an accent line across `x0..x1` at `y`.
|
||||
pub fn insertion_line(painter: &egui::Painter, x0: f32, x1: f32, y: f32) {
|
||||
painter.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(2.0, crate::theme::ACCENT));
|
||||
}
|
||||
|
||||
/// What a drag carries, following the pointer at `at`: `galley` in a small outlined box above
|
||||
/// everything else, with the grabbing hand.
|
||||
pub fn drag_ghost(ctx: &egui::Context, at: egui::Pos2, galley: std::sync::Arc<egui::Galley>) {
|
||||
let t = Tokens::get(ctx);
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("drag_ghost")));
|
||||
let r = Rect::from_min_size(at + vec2(14.0, 10.0), galley.size() + vec2(16.0, 8.0));
|
||||
painter.rect_filled(r, CornerRadius::same(5), t.panel);
|
||||
painter.rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
|
||||
painter.galley(r.min + vec2(8.0, 4.0), galley, t.text);
|
||||
}
|
||||
|
||||
pub fn separator(ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
|
|
|
|||
28
docs/mcp.md
28
docs/mcp.md
|
|
@ -419,6 +419,34 @@ and a file keeps the newest 20 autosaves (user versions are never dropped for th
|
|||
order: `{items: [{id, name, page, status}]}`. The Inspect tab sets the status of the selected
|
||||
frames and, with nothing selected, lists them with a status filter (click one to go to it).
|
||||
|
||||
### Paint, effect and layout grid lists
|
||||
|
||||
A layer's fills, strokes, effects and layout grids are lists kept **bottom first**: index 0 paints
|
||||
first, the last one is on top (the Design panel lists them the other way round, topmost first).
|
||||
|
||||
- **Reorder:** `fill.move`, `stroke.move`, `effect.move` and `grid.move {ids?, from, to}` move one
|
||||
item (one undo step for every layer given; out-of-range or equal indices change nothing, negative
|
||||
or fractional ones are refused). The item keeps everything (an image fill its image, scale mode
|
||||
and adjustments, a gradient its stops and handles), and a paint, effect or grid style linked to
|
||||
the list is unlinked (the list no longer matches it). In the app, drag a row by its grip (the
|
||||
empty strip left of its fields); a line shows where it lands.
|
||||
- **Variable bindings follow their items** when a list is reordered or an item removed
|
||||
(`fill.remove`, `stroke.remove`, `effect.remove`, `grid.remove`); a removed item's bindings go
|
||||
with it. Binding fields (`variable.bind {field}`): `fill` / `stroke` (the first paint's colour) or
|
||||
`fills/<i>/color` / `strokes/<i>/color`, `effects/<i>/<offsetX|offsetY|radius|spread|color>`,
|
||||
`layoutGrids/<i>/<size|count|gutter|offset>` (number variables; count is kept to 1–1000, size and
|
||||
gutter are never negative). Moving a bound paint rewrites its key (`fill` becomes `fills/2/color`).
|
||||
- **Instance properties:** `variable.bind {ids: [instance], field: "componentProperties/<name>",
|
||||
variable}` drives a boolean property from a boolean variable and a text property from a string
|
||||
variable (other types are refused); the value follows the variable's modes like any binding, the
|
||||
binding follows renames of the property, and deleting the property, resetting the instance or
|
||||
detaching it drops it. The instance's properties panel offers the same with its variable button.
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"fill.move","params":{"ids":[2],"from":0,"to":2}}}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"variable.bind","params":{"ids":[5],"field":"layoutGrids/0/gutter","variable":"gutter"}}}}
|
||||
```
|
||||
|
||||
### Ruler guides
|
||||
|
||||
`guide.add {vertical, offset, frame? | page?}` (returns `{index}`), `guide.move {index, offset, …}`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue