M5: drag to reorder prototype actions; variable mode lookups in expressions (#94)

- Interaction editor: actions (top level and inside conditional blocks) reorder by dragging
  their row's grip (list_drag, now also for lists shown in model order), one proto.action.move
  per drop; the open action stays open where it lands; Escape cancels a row drag without
  deselecting or closing what holds the list. The ... menu keeps Move up / Move down.
- Prototype expressions: `accent[Dark]` reads a variable's value in one mode of its collection
  (Expr::InMode) and `mode(Theme)` is the name of the mode a collection is in where the
  interaction runs (Expr::Mode); parsed, type-checked, displayed, stored by id (names accepted in
  JSON), evaluated with the layer's modes. REST import maps VAR_MODE_LOOKUP over a variable and
  one of its modes.
- Overlays opened through commands close on a click outside unless told not to, as in the editor.
- Collection::find_mode / current_mode replace repeated lookups.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 22:21:53 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent be7d12f00c
commit c662e56de4
20 changed files with 760 additions and 71 deletions
+14 -11
View File
@@ -161,16 +161,20 @@ dimension measured side by side.
presenter's flow picker and under each flow; conditional actions (if / else if / else over
variable expressions) and set-variable-mode actions run in the presenter (from REST files or
commands). Conditionals are built in the interaction editor: an interaction's actions are a list
(add, reorder, remove, open one to edit it), "Add action ▸ Conditional" adds an if whose
(add, reorder by dragging a row's grip, also inside conditional blocks, or from its menu, remove,
open one to edit it; one undo step per move), "Add action ▸ Conditional" adds an if whose
condition comes from a builder (a variable from the variable picker, an operator, a value or
another variable) or is typed as an expression (`count >= 3 and not done`, checked when entered,
what's wrong shown under the field), each block with its own actions (the same editors,
nested), plus "Else if" and "Else"; set variable values can be expressions (`count + 1`,
`"Hi " + name`); every edit is an undo step (`proto.action.*`, `proto.conditional.*`). Hotspot
hints: a click where nothing reacts flashes the screen's hotspots (an Options menu toggle, on by
default); the presenter's Inspect panel shows the hovered layer's properties. *Left:* dragging
actions to reorder them (a menu moves them now), QA against larger prototypes with
conditionals. 2–4 h.
`"Hi " + name`); expressions read a variable's value in a given mode of its collection
(`accent[Dark]`, also from REST files' variable mode lookups) and the mode a collection is in
where the interaction runs (`mode(Theme) == "Dark"`, following set variable mode actions); every
edit is an undo step (`proto.action.*`, `proto.conditional.*`). Hotspot hints: a click where
nothing reacts flashes the screen's hotspots (an Options menu toggle, on by default); the
presenter's Inspect panel shows the hovered layer's properties. Overlays opened through commands
close on a click outside unless told not to, as in the editor. *Left:* QA against larger
real-world prototypes. 1–2 h.
6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import`
/ CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry
paths, auto layout, constraints, grids, components, sets, instances with overrides, component
@@ -201,9 +205,8 @@ dimension measured side by side.
past the frame (its fills reach out, its children are clipped at the bleed), crop and bleed
marks outside it, MediaBox / BleedBox / TrimBox, and CMYK colour (a documented, deterministic
naive conversion; images and rasterised effects too).
*Left:* .fig import (best effort), variable mode lookups in prototype expressions, a CMYK
output intent (no CMYK ICC profile can ship in the repository), SVG filters other than shadows
and blurs (left out with a warning). 8–16 h.
*Left:* .fig import (best effort), a CMYK output intent (no CMYK ICC profile can ship in the
repository), SVG filters other than shadows and blurs (left out with a warning). 8–16 h.
7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the
public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas,
the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to
@@ -341,8 +344,8 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Text | 8 | 79% | visual QA | 0.5–1 |
| Components & variants | 9 | 79% | QA with real-world (not synthetic) libraries | 1 |
| 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 | 89% | dragging actions to reorder them, QA against larger prototypes with conditionals | 2–4 |
| Import/export | 6 | 71% | .fig import (best effort), variable-mode lookups in prototype expressions, SVG filters beyond shadows and blurs and rotated repeating patterns, a CMYK output intent for print PDFs (needs a CMYK ICC profile) | 8–16 |
| Prototyping | 6 | 91% | QA against larger real-world prototypes | 1–2 |
| Import/export | 6 | 71% | .fig import (best effort), SVG filters beyond shadows and blurs and rotated repeating patterns, a CMYK output intent for print PDFs (needs a CMYK ICC profile) | 8–16 |
| Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 |
| Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 |
+2
View File
@@ -866,6 +866,8 @@ mod effects_tests;
#[cfg(test)]
mod print_tests;
#[cfg(test)]
mod proto_expr_mode_tests;
#[cfg(test)]
mod proto_expr_parse_tests;
#[cfg(test)]
mod proto_expr_tests;
+1 -1
View File
@@ -438,7 +438,7 @@ pub struct Node {
pub annotations: Vec<Annotation>,
}
fn yes() -> bool {
pub(crate) fn yes() -> bool {
true
}
fn one() -> f32 {
+2 -1
View File
@@ -373,7 +373,8 @@ pub enum Action {
dest: NodeId,
#[serde(default)]
position: OverlayPosition,
#[serde(default, alias = "closeOnOutside")]
/// A click outside the overlay closes it (on unless turned off, as new overlays start).
#[serde(default = "crate::node::yes", alias = "closeOnOutside")]
close_on_outside: bool,
/// Colour drawn behind the overlay (over the frame underneath); None = no background.
#[serde(default)]
+40 -3
View File
@@ -1,5 +1,6 @@
//! Prototype expressions: the values set-variable actions write and the conditions of
//! conditional actions, built from literals, variable references and operators.
//! conditional actions, built from literals, variable references (also a variable's value in a
//! given mode of its collection), the current mode of a collection, and operators.
use std::collections::BTreeMap;
@@ -20,6 +21,12 @@ pub enum Expr {
Value(VariableValue),
/// An operator applied to its arguments.
Op { op: ExprOp, args: Vec<Expr> },
/// The name of the mode collection `collection` is in where the expression is evaluated (the
/// layer whose interaction runs), as text: `mode(Theme) == "Dark"`.
Mode { collection: CollectionId },
/// Variable `variable`'s value in mode `mode` of its collection, whichever mode is chosen:
/// `accent[Dark]`.
InMode { variable: String, mode: ModeId },
}
/// Expression operators.
@@ -96,6 +103,14 @@ impl Expr {
Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes),
Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)),
Expr::Value(v) => return Some(v.clone()),
Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())),
Expr::InMode { variable, mode } => {
let v = vars.get(variable)?;
let c = vars.collection(&v.collection)?;
let mut at = modes.clone();
at.insert(c.id.clone(), c.find_mode(mode)?.id.clone());
return vars.resolve(&v.id, &at);
}
Expr::Op { op, args } => (*op, args),
};
let vals: Vec<VariableValue> = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::<Option<_>>()?;
@@ -111,7 +126,7 @@ impl Expr {
fn collect_variables<'a>(&'a self, out: &mut Vec<&'a str>, depth: usize) {
match self {
Expr::Value(VariableValue::Alias(id)) => out.push(id),
Expr::Value(VariableValue::Alias(id)) | Expr::InMode { variable: id, .. } => out.push(id),
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter().for_each(|a| a.collect_variables(out, depth + 1)),
_ => {}
}
@@ -129,11 +144,21 @@ impl Expr {
}
match self {
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
Expr::Value(VariableValue::String(s)) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
Expr::Value(VariableValue::String(s)) => quote(s),
// Exact numbers and `#` colours, so the text reads back as the same expression.
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
Expr::Value(VariableValue::Color(c)) => c.to_css_hex(),
Expr::Value(v) => v.display(),
Expr::Mode { collection } => {
let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str());
format!("mode({})", name_arg(name))
}
Expr::InMode { variable, mode } => {
let var = vars.get(variable);
let mode =
var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str());
format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode))
}
Expr::Op { op, args } => {
let parts: Vec<String> = args
.iter()
@@ -153,6 +178,18 @@ impl Expr {
}
}
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
/// spaces at its ends, quotes or brackets.
fn name_arg(name: &str) -> String {
let plain = !name.is_empty() && name.trim() == name && !name.contains(['"', '\'', '\\', '(', ')', '[', ']', '{', '}']);
if plain { name.to_string() } else { quote(name) }
}
/// `s` as a double-quoted text literal ([`Expr::parse`] reads it back).
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
fn number(v: &VariableValue) -> Option<f64> {
match v {
VariableValue::Number(n) => Some(*n),
+136
View File
@@ -0,0 +1,136 @@
//! Variable mode lookups in prototype expressions: a variable's value in a given mode of its
//! collection (`n-name[Dark]`) and the mode a collection is in (`mode(Theme)`), typed, checked,
//! evaluated, written back and stored as JSON.
use std::collections::BTreeMap;
use crate::proto::*;
use crate::proto_expr_tests::vars;
use crate::{Collection, Mode, Variable, VariableType, VariableValue, Variables};
/// The test variables plus a collection "Color scheme" (modes "High contrast" and "a]b") with a
/// number variable `k` ("k-name").
fn more_vars() -> Variables {
let mut v = vars();
v.collections.push(Collection {
id: "cs".into(),
name: "Color scheme".into(),
modes: vec![Mode { id: "hc".into(), name: "High contrast".into() }, Mode { id: "odd".into(), name: "a]b".into() }],
});
v.variables.push(Variable {
id: "k".into(),
name: "k-name".into(),
collection: "cs".into(),
kind: VariableType::Number,
values: BTreeMap::from([("hc".to_string(), VariableValue::Number(1.0)), ("odd".to_string(), VariableValue::Number(2.0))]),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
});
v
}
fn parse(text: &str) -> Expr {
Expr::parse(text, &more_vars()).unwrap_or_else(|e| panic!("{text}: {e:?}"))
}
fn error(text: &str) -> String {
match Expr::parse(text, &more_vars()) {
Ok(e) => panic!("{text} parsed as {e:?}"),
Err(e) => e.message,
}
}
fn in_mode(var: &str, mode: &str) -> Expr {
Expr::InMode { variable: var.into(), mode: mode.into() }
}
fn mode_of(c: &str) -> Expr {
Expr::Mode { collection: c.into() }
}
#[test]
fn reads_mode_lookups_and_writes_them_back() {
use ExprOp::*;
assert_eq!(parse("n-name[Dark]"), in_mode("n", "dark"));
assert_eq!(parse("n-name [ dark ] + 1"), Expr::op(Add, vec![in_mode("n", "dark"), Expr::Value(VariableValue::Number(1.0))]), "ids, spaces");
assert_eq!(parse("{k-name}[High contrast]"), in_mode("k", "hc"));
assert_eq!(parse(r#"k-name["a]b"]"#), in_mode("k", "odd"), "quoted names may hold brackets");
assert_eq!(parse("mode(Theme)"), mode_of("c"));
assert_eq!(
parse("MODE ( 'Color scheme' ) == \"High contrast\""),
Expr::op(Equals, vec![mode_of("cs"), Expr::Value(VariableValue::String("High contrast".into()))])
);
assert_eq!(parse("mode({Color scheme})"), mode_of("cs"));
assert_eq!(parse("mode(c) != \"Dark\" and n-name[light] < n-name"), parse("(mode(Theme) != 'Dark') and (n-name[Light] < n)"));
let vars = more_vars();
for text in [
"n-name[Dark]",
"k-name[High contrast]",
"k-name[\"a]b\"]",
"mode(Theme) == \"Dark\"",
"mode(Color scheme)",
"(mode(Theme) == \"Light\") or (k-name[High contrast] > 0)",
] {
let e = parse(text);
assert_eq!(e.display(&vars), text, "{e:?}");
assert_eq!(Expr::parse(&e.display(&vars), &vars), Ok(e.clone()), "{text} reads back");
// Stored as JSON and read back.
let json = serde_json::to_value(&e).unwrap();
assert_eq!(serde_json::from_value::<Expr>(json).unwrap(), e);
}
assert_eq!(serde_json::to_value(in_mode("n", "dark")).unwrap(), serde_json::json!({"inMode": {"variable": "n", "mode": "dark"}}));
assert_eq!(serde_json::to_value(mode_of("c")).unwrap(), serde_json::json!({"mode": {"collection": "c"}}));
}
#[test]
fn mode_lookup_errors_say_what_is_wrong() {
assert_eq!(error("n-name[Sepia]"), "Theme has no mode `Sepia`");
assert_eq!(error("n-name[High contrast]"), "Theme has no mode `High contrast`", "another collection's mode");
assert_eq!(error("n-name[Dark"), "Add a closing ]");
assert_eq!(error("n-name[]"), "Name a mode between the brackets");
assert_eq!(error("mode(Nope)"), "No variable collection `Nope`");
assert_eq!(error("mode(Theme"), "Add a closing )");
assert_eq!(error("mode()"), "Name a collection between the brackets");
assert_eq!(error("mode(\"Theme)"), "Close the text with \"");
// `mode` alone isn't a function call (nor a variable here).
assert_eq!(error("mode"), "No variable `mode`");
// Type checks: a lookup has its variable's type, a mode is text.
let vars = more_vars();
assert_eq!(parse("n-name[Dark] + 1").type_of(&vars), Ok(VariableType::Number));
assert_eq!(parse("mode(Theme)").type_of(&vars), Ok(VariableType::String));
assert_eq!(parse("mode(Theme) == 1").check_condition(&vars), Err("Can't compare text with a number".into()));
assert_eq!(parse("mode(Theme) == 'Dark'").check_condition(&vars), Ok(()));
// Stored expressions whose collection, variable or mode is gone.
assert_eq!(mode_of("gone").type_of(&vars), Err("No variable collection `gone`".into()));
assert_eq!(in_mode("gone", "dark").type_of(&vars), Err("No variable `gone`".into()));
assert_eq!(in_mode("n", "gone").type_of(&vars), Err("Theme has no mode `gone`".into()));
assert_eq!(in_mode("n", "gone").display(&vars), "n-name[\"(missing mode)\"]");
assert_eq!(mode_of("gone").display(&vars), "mode(\"(missing collection)\")");
}
#[test]
fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
let vars = more_vars();
let light = BTreeMap::new();
let dark = BTreeMap::from([("c".to_string(), "dark".to_string())]);
let ev = |text: &str, modes: &BTreeMap<String, String>| parse(text).eval(&vars, modes);
// A lookup reads its mode whichever mode is chosen; plain references follow the chosen one.
assert_eq!(ev("n-name[Dark]", &light), Some(VariableValue::Number(7.0)));
assert_eq!(ev("n-name[Light]", &dark), Some(VariableValue::Number(3.0)));
assert_eq!(ev("n-name", &dark), Some(VariableValue::Number(7.0)));
// Aliases resolve in the looked-up mode.
assert_eq!(ev("alias-name[Dark]", &light), Some(VariableValue::Number(7.0)));
// The current mode by name: the default when none is chosen (or the chosen one is gone).
assert_eq!(ev("mode(Theme)", &light), Some(VariableValue::String("Light".into())));
assert_eq!(ev("mode(Theme)", &dark), Some(VariableValue::String("Dark".into())));
assert_eq!(
parse("mode(Theme)").eval(&vars, &BTreeMap::from([("c".to_string(), "gone".to_string())])),
Some(VariableValue::String("Light".into()))
);
assert!(parse("mode(Theme) == \"Dark\" and k-name[\"a]b\"] == 2").holds(&vars, &dark));
// Missing parts evaluate to nothing.
assert_eq!(in_mode("n", "gone").eval(&vars, &light), None);
assert_eq!(mode_of("gone").eval(&vars, &light), None);
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
}
+88 -5
View File
@@ -3,8 +3,11 @@
//! gives before it is saved.
//!
//! Variables are written by name (a name with spaces or symbols reads as typed: the longest name
//! that fits wins; `{name}` is always a variable). Literals: numbers, `"text"` or `'text'`,
//! `true` / `false`, `#RRGGBB` colours. Operators, loosest first: `or` (`||`), `and` (`&&`), `not`
//! that fits wins; `{name}` is always a variable). A variable followed by a mode in brackets
//! (`accent[Dark]`, `{Brand color}["High contrast"]`) reads its value in that mode of its
//! collection; `mode(Theme)` is the name of the mode a collection is in where the expression runs
//! (`mode(Theme) == "Dark"`). Literals: numbers, `"text"` or `'text'`, `true` / `false`,
//! `#RRGGBB` colours. Operators, loosest first: `or` (`||`), `and` (`&&`), `not`
//! (`!`), comparisons (`==` or `=`, `!=`, `<`, `<=`, `>`, `>=`), `+` / `-`, `*` / `/`, unary `-`.
//! [`Expr::display`] writes what this reads back.
@@ -88,6 +91,14 @@ impl Expr {
Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()),
Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()),
Expr::Mode { collection } => {
return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
}
Expr::InMode { variable, mode } => {
let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
}
Expr::Op { op, args } => (*op, args),
};
let ts = args.iter().map(|a| a.type_at(vars, depth + 1)).collect::<Result<Vec<T>, String>>()?;
@@ -189,6 +200,11 @@ impl Parser<'_> {
word.chars().enumerate().all(|(k, c)| self.peek(k).is_some_and(|x| x.to_lowercase().eq(c.to_lowercase()))) && !self.peek(n).is_some_and(ident)
}
/// The first character from `k` characters on that isn't whitespace.
fn next_after(&self, k: usize) -> Option<char> {
self.s.iter().skip(self.i + k).copied().find(|c| !c.is_whitespace())
}
fn at_symbol(&self, sym: &str) -> bool {
sym.chars().enumerate().all(|(k, c)| self.peek(k) == Some(c))
}
@@ -365,6 +381,11 @@ impl Parser<'_> {
}
fn string(&mut self, quote: char) -> Parsed {
self.text(quote).map(|t| Expr::Value(VariableValue::String(t)))
}
/// The text of a literal in `quote`s starting here.
fn text(&mut self, quote: char) -> Result<String, ExprError> {
let start = self.i;
self.i += 1;
let mut out = String::new();
@@ -373,7 +394,7 @@ impl Parser<'_> {
None => return Err(ExprError { message: format!("Close the text with {quote}"), at: start }),
Some(c) if c == quote => {
self.i += 1;
return Ok(Expr::Value(VariableValue::String(out)));
return Ok(out);
}
Some('\\') => {
match self.peek(1) {
@@ -413,7 +434,7 @@ impl Parser<'_> {
let name: String = self.s.get(start + 1..end).unwrap_or_default().iter().collect();
self.i = end + 1;
match self.vars.get(name.trim()) {
Some(v) => Ok(Expr::var(v.id.clone())),
Some(v) => self.in_mode(v.id.clone()),
None => Err(ExprError { message: format!("No variable `{}`", name.trim()), at: start }),
}
}
@@ -434,9 +455,13 @@ impl Parser<'_> {
c if c.is_ascii_digit() || c == '.' => self.number(),
')' => Err(self.err("Unexpected )")),
_ => {
// `mode(…)`, also when a variable is named "mode" (a variable can't take brackets).
if self.at_keyword("mode") && self.next_after(4) == Some('(') {
return self.mode_of();
}
if let Some((id, n)) = self.variable_here() {
self.i += n;
return Ok(Expr::var(id));
return self.in_mode(id);
}
for (word, v) in [("true", true), ("false", false)] {
if self.at_keyword(word) {
@@ -450,6 +475,64 @@ impl Parser<'_> {
}
}
/// `mode(collection)` starting here: the mode the collection is in.
fn mode_of(&mut self) -> Parsed {
let start = self.i;
while self.peek(0).is_some_and(|c| c != '(') {
self.i += 1;
}
self.i += 1;
let name = self.argument(')', "collection")?;
match self.vars.collection(&name) {
Some(c) => Ok(Expr::Mode { collection: c.id.clone() }),
None => Err(ExprError { message: format!("No variable collection `{name}`"), at: start }),
}
}
/// Variable `var`, or with a mode in brackets after it its value in that mode of its collection.
fn in_mode(&mut self, var: String) -> Parsed {
if self.next_after(0) != Some('[') {
return Ok(Expr::var(var));
}
self.ws();
self.i += 1;
let start = self.i;
let name = self.argument(']', "mode")?;
let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection));
match coll.and_then(|c| c.find_mode(&name)) {
Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }),
None => {
let whose = coll.map_or("The variable's collection", |c| c.name.as_str());
Err(ExprError { message: format!("{whose} has no mode `{name}`"), at: start })
}
}
}
/// A collection or mode name up to `close` (quoted, or as typed with the spaces around it and
/// any `{…}` dropped), then past `close`.
fn argument(&mut self, close: char, what: &str) -> Result<String, ExprError> {
self.ws();
let start = self.i;
let name = match self.peek(0) {
Some(q @ ('"' | '\'')) => self.text(q)?,
_ => {
let mut raw = String::new();
self.take(&mut raw, |c| c != close);
let t = raw.trim();
t.strip_prefix('{').and_then(|x| x.strip_suffix('}')).unwrap_or(t).trim().to_string()
}
};
self.ws();
if self.peek(0) != Some(close) {
return Err(self.err(format!("Add a closing {close}")));
}
self.i += 1;
if name.is_empty() {
return Err(ExprError { message: format!("Name a {what} between the brackets"), at: start });
}
Ok(name)
}
/// What follows a complete expression that shouldn't.
fn unexpected(&self) -> ExprError {
let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect();
+11
View File
@@ -116,6 +116,17 @@ impl Collection {
pub fn default_mode(&self) -> Option<&ModeId> {
self.modes.first().map(|m| &m.id)
}
/// The mode with id `key`, else the one named `key`.
pub fn find_mode(&self, key: &str) -> Option<&Mode> {
self.modes.iter().find(|m| m.id == key).or_else(|| self.modes.iter().find(|m| m.name == key))
}
/// The mode the collection is in under `modes` (collection → chosen mode): the chosen one if it
/// is one of its modes, else the default.
pub fn current_mode(&self, modes: &BTreeMap<CollectionId, ModeId>) -> Option<&Mode> {
modes.get(&self.id).and_then(|m| self.modes.iter().find(|x| x.id == *m)).or_else(|| self.modes.first())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+16 -3
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate {preserveScroll?}|back|scrollTo|openOverlay {position?, close_on_outside?, background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate {preserveScroll?}|back|scrollTo|openOverlay {position?, close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
@@ -313,7 +313,7 @@ pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
}
Action::SetVariableMode { collection, mode } => {
if let Some(c) = vars.collection(collection) {
if let Some(m) = c.modes.iter().find(|m| m.id == *mode).or_else(|| c.modes.iter().find(|m| m.name == *mode)) {
if let Some(m) = c.find_mode(mode) {
*mode = m.id.clone();
}
*collection = c.id.clone();
@@ -328,7 +328,7 @@ pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
});
}
/// Variables an expression reads by name, stored by id.
/// Variables, collections and modes an expression names by name, stored by id.
pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
match e {
Expr::Value(VariableValue::Alias(name)) => {
@@ -336,6 +336,19 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
*name = v.id.clone();
}
}
Expr::Mode { collection } => {
if let Some(c) = vars.collection(collection) {
*collection = c.id.clone();
}
}
Expr::InMode { variable, mode } => {
if let Some(v) = vars.get(variable) {
if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) {
*mode = m.id.clone();
}
*variable = v.id.clone();
}
}
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| canonical_expr(vars, a, depth + 1)),
_ => {}
}
+2
View File
@@ -1072,6 +1072,8 @@ mod proto_conditional_tests;
#[cfg(test)]
mod proto_multi_tests;
#[cfg(test)]
mod proto_polish_tests;
#[cfg(test)]
mod proto_present_tests;
#[cfg(test)]
mod proto_responsive_tests;
+120
View File
@@ -0,0 +1,120 @@
//! Variable mode lookups in prototype expressions through the interaction commands: typed as text
//! or given as JSON by name, stored by id, checked, and evaluated where the interaction runs (the
//! current mode of a collection follows set variable mode actions; a lookup reads its own mode).
//! Overlays opened through the commands close on a click outside unless told not to, as in the
//! interaction editor.
use figcraft_doc::proto::{Action, Expr, ExprOp};
use figcraft_doc::{NodeId, VariableValue};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::proto_run::{branch, set_mode, set_variable};
/// Frames A and B, a button in A (click → navigate to B), and a collection Theme (Light, Dark)
/// with a text variable `label` ("Day" in Light, "Night" in Dark).
fn setup() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
let id = |s: &mut Session, c: &str, p: Value| id_of(&s.execute(c, &p).unwrap()["id"]).unwrap();
let a = id(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A"}));
let b = id(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100, "name": "B"}));
let btn = id(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20, "parent": a}));
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "label", "type": "string", "value": "Day"})).unwrap();
s.execute("variable.set", &json!({"variable": "label", "mode": "Dark", "value": "Night"})).unwrap();
s.execute("proto.interaction.add", &json!({"id": btn, "dest": b})).unwrap();
(s, a, btn)
}
/// (collection id, Light id, Dark id, label id).
fn ids(s: &Session) -> (String, String, String, String) {
let vars = &s.doc().unwrap().variables;
let c = vars.collection("Theme").unwrap();
(c.id.clone(), c.modes[0].id.clone(), c.modes[1].id.clone(), vars.get("label").unwrap().id.clone())
}
fn text(s: &str) -> Expr {
Expr::Value(VariableValue::String(s.into()))
}
#[test]
fn conditions_on_a_collections_mode_follow_set_variable_mode() {
let (mut s, a, btn) = setup();
let (theme, light, dark, label) = ids(&s);
// If Theme is in Dark, set label to its Light value; else switch Theme to Dark.
s.execute(
"proto.action.add",
&json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [
{"expression": "mode(Theme) == \"Dark\"", "actions": [{"type": "setVariable", "variable": "label", "expression": "label[Light] + \"!\""}]},
{"actions": [{"type": "setVariableMode", "collection": "Theme", "mode": "Dark"}]},
]}}),
)
.unwrap();
let doc = s.doc().unwrap().clone();
let Some(Action::Conditional { blocks }) = doc.node(btn).unwrap().interactions[0].actions.get(1).cloned() else { panic!() };
assert_eq!(blocks[0].condition, Some(Expr::op(ExprOp::Equals, vec![Expr::Mode { collection: theme.clone() }, text("Dark")])), "stored by id");
let Some(Action::SetVariable { expr: Some(e), .. }) = blocks[0].actions.first() else { panic!() };
assert_eq!(e, &Expr::op(ExprOp::Add, vec![Expr::InMode { variable: label.clone(), mode: light.clone() }, text("!")]));
// In Light the else runs; once the prototype switched Theme to Dark, the if.
let mut doc = doc;
assert!(matches!(branch(&doc, btn, &blocks).as_slice(), [Action::SetVariableMode { .. }]));
assert!(set_mode(&mut doc, a, "Theme", "Dark"));
assert_eq!(doc.modes_for(btn).get(&theme), Some(&dark));
let run = branch(&doc, btn, &blocks);
let [Action::SetVariable { variable, value, expr }] = run.as_slice() else { panic!("{run:?}") };
// The lookup reads Light's value although the button is in Dark.
assert!(set_variable(&mut doc, btn, variable, value, expr.as_ref()));
assert_eq!(doc.variables.resolve(&label, &doc.modes_for(btn)), Some(VariableValue::String("Day!".into())));
}
#[test]
fn json_lookups_by_name_are_stored_by_id_and_checked() {
let (mut s, _, btn) = setup();
let (theme, light, _, label) = ids(&s);
let r = s.execute(
"proto.action.add",
&json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [
{"condition": {"op": {"op": "notEqual", "args": [{"inMode": {"variable": "label", "mode": "Light"}}, {"mode": {"collection": "Theme"}}]}}, "actions": []},
]}}),
);
assert!(r.is_ok(), "{r:?}");
let doc = s.doc().unwrap();
let Some(Action::Conditional { blocks }) = doc.node(btn).unwrap().interactions[0].actions.get(1) else { panic!() };
assert_eq!(
blocks[0].condition,
Some(Expr::op(ExprOp::NotEqual, vec![Expr::InMode { variable: label, mode: light }, Expr::Mode { collection: theme }]))
);
assert_eq!(blocks[0].condition.as_ref().unwrap().display(&doc.variables), "label[Light] != mode(Theme)");
// Unknown modes and collections, and a mode used as a number, fail with what's wrong.
for (expression, why) in [
("label[Sepia] == \"x\"", "Theme has no mode `Sepia`"),
("mode(Colors) == \"x\"", "No variable collection `Colors`"),
("mode(Theme) > 1", "`>` compares two numbers or two texts"),
] {
let err =
s.execute("proto.conditional.addBlock", &json!({"id": btn, "index": 0, "path": [1], "expression": expression})).unwrap_err().to_string();
assert!(err.contains(why), "{expression}: {err}");
}
let err = s
.execute("proto.action.add", &json!({"id": btn, "index": 0, "action": {"type": "setVariable", "variable": "label", "value": null, "expr": {"inMode": {"variable": "label", "mode": "Sepia"}}}}))
.unwrap_err()
.to_string();
assert!(err.contains("Theme has no mode `Sepia`"), "{err}");
}
#[test]
fn overlays_from_commands_close_on_a_click_outside_by_default() {
let (mut s, _, btn) = setup();
let menu = id_of(&s.execute("frame.create", &json!({"x": 400, "y": 0, "width": 80, "height": 60, "name": "Menu"})).unwrap()["id"]).unwrap();
for (extra, closes) in [(json!({}), true), (json!({"closeOnOutside": false}), false), (json!({"close_on_outside": false}), false)] {
let mut action = json!({"type": "openOverlay", "dest": menu});
if let (Some(o), Value::Object(x)) = (action.as_object_mut(), extra) {
o.extend(x);
}
s.execute("proto.action.set", &json!({"id": btn, "index": 0, "path": [0], "action": action})).unwrap();
let doc = s.doc().unwrap();
let Some(Action::OpenOverlay { close_on_outside, .. }) = doc.node(btn).unwrap().interactions[0].actions.first() else { panic!() };
assert_eq!(*close_on_outside, closes);
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va
/// win), then re-apply bindings. False for an unknown collection or mode.
pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
let Some(c) = doc.variables.collection(collection) else { return false };
let Some(mode) = c.modes.iter().find(|m| m.id == mode).or_else(|| c.modes.iter().find(|m| m.name == mode)).map(|m| m.id.clone()) else {
let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else {
return false;
};
let cid = c.id.clone();
+42 -1
View File
@@ -144,11 +144,45 @@ fn unsupported_conditions_skip_the_whole_conditional() {
on("AFTER_TIMEOUT", vec![set(SPACE, expr("ADDITION", vec![]))]),
]);
assert!(button(&doc).interactions.is_empty(), "{:#?}", button(&doc).interactions);
for w in ["unsupported functions", "conditions that can't be evaluated", "variables that aren't in the variables JSON", "without a destination"] {
for w in ["variable mode lookups", "conditions that can't be evaluated", "variables that aren't in the variables JSON", "without a destination"] {
assert!(has(&warnings, w), "{w}: {warnings:?}");
}
}
#[test]
fn variable_mode_lookups_read_a_variable_in_one_mode() {
let lookup = |v: &str, mode: Value| json!({"type": "EXPRESSION", "value": {"expressionFunction": "VAR_MODE_LOOKUP", "expressionArguments": [var(v), mode]}});
let (doc, warnings) = import_file(vec![
// The label as it reads in Light, whatever mode the button is in.
on("ON_CLICK", vec![set(LABEL, lookup(LABEL, data("STRING", json!("1:0"))))]),
on(
"ON_HOVER",
vec![json!({"type": "CONDITIONAL", "conditionalBlocks": [
{"condition": expr("EQUALS", vec![lookup(SPACE, json!({"value": "1:0"})), data("FLOAT", json!(8))]), "actions": [navigate("1:3")]}
]})],
),
// Not a mode of the variable's collection, or not a variable: skipped.
on("ON_PRESS", vec![set(LABEL, lookup(LABEL, data("STRING", json!("9:9"))))]),
on(
"ON_DRAG",
vec![set(
LABEL,
json!({"type": "EXPRESSION", "value": {"expressionFunction": "VAR_MODE_LOOKUP", "expressionArguments": [data("STRING", json!("1:0")), var(LABEL)]}}),
)],
),
]);
let its = &button(&doc).interactions;
assert_eq!(its.len(), 2, "{its:#?}");
let Action::SetVariable { expr: Some(e), .. } = &its[0].actions[0] else { panic!("{its:#?}") };
assert_eq!(e, &Expr::InMode { variable: LABEL.into(), mode: "1:0".into() });
// Dark is the default mode: the lookup still reads Light's value.
assert_eq!(e.eval(&doc.variables, &Default::default()), Some(VariableValue::String("Hello".into())));
assert_eq!(e.display(&doc.variables), "copy/label[Light]");
let Action::Conditional { blocks } = &its[1].actions[0] else { panic!() };
assert!(blocks[0].condition.as_ref().unwrap().holds(&doc.variables, &Default::default()));
assert!(has(&warnings, "variable mode lookups"), "{warnings:?}");
}
#[test]
fn nested_conditionals_and_depth_limit() {
// if FLAG { if SPACE == 8 { navigate } else { back } }
@@ -181,6 +215,13 @@ fn junk_in_conditional_actions_never_panics() {
]})],
),
on("ON_HOVER", vec![set(FLAG, expr("OR", vec![var(FLAG), expr("NEGATE", vec![var(FLAG)])]))]),
on(
"ON_PRESS",
vec![set(
LABEL,
json!({"type": "EXPRESSION", "value": {"expressionFunction": "VAR_MODE_LOOKUP", "expressionArguments": [var(LABEL), data("STRING", json!("1:1"))]}}),
)],
),
]);
let mut combined = base.clone();
combined["meta"] = variables_json()["meta"].clone();
+23 -1
View File
@@ -355,6 +355,9 @@ impl Ctx<'_> {
return None;
}
if let Some((function, args)) = expr_parts(data) {
if function == "VAR_MODE_LOOKUP" {
return self.mode_lookup(args);
}
let op = match function {
"ADDITION" => ExprOp::Add,
"SUBTRACTION" => ExprOp::Subtract,
@@ -371,7 +374,7 @@ impl Ctx<'_> {
"NOT" => ExprOp::Not,
"NEGATE" => ExprOp::Negate,
_ => {
self.warn("prototype expressions with unsupported functions (like variable mode lookups) were skipped");
self.warn("prototype expressions with unsupported functions were skipped");
return None;
}
};
@@ -402,6 +405,25 @@ impl Ctx<'_> {
variables::value(kind, v).map(Expr::Value)
}
/// A variable mode lookup (`VAR_MODE_LOOKUP` over a variable reference and the id of one of its
/// collection's modes): the variable's value in that mode.
fn mode_lookup(&mut self, args: &[Value]) -> Option<Expr> {
let parts = match args {
[var, mode] => var.get("value").and_then(variables::alias).zip(mode.get("value").and_then(Value::as_str)),
_ => None,
};
let vars = &self.doc.variables;
let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| {
let v = vars.get(id)?;
let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() })
});
if found.is_none() {
self.warn("variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped");
}
found
}
/// The imported layer a REST id names.
fn dest(&self, rest_id: &str) -> Option<NodeId> {
self.ids.get(rest_id).copied()
+2
View File
@@ -74,6 +74,8 @@ mod present_view_tests;
#[cfg(test)]
mod proto_editor_tests;
#[cfg(test)]
mod proto_reorder_tests;
#[cfg(test)]
mod proto_responsive_tests;
#[cfg(test)]
mod shortcuts_tests;
+72 -30
View File
@@ -1,29 +1,42 @@
//! 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.
//! dragged in the layers panel; dropping moves it with one command (one undo step). The Design
//! panel shows the topmost item first while the model keeps lists bottom first; other lists (an
//! interaction's actions) show their items in model order.
use egui::{Id, Rect, Sense, Ui, pos2, vec2};
use egui::{AsId, 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 {
/// The id of list `list` (any hashable key, e.g. `"fills"`).
fn list_id(list: impl AsId) -> Id {
Id::new(("list_drag", list))
}
/// The row of list `list` being dragged (its model index; temp data).
fn drag_id(list: Id) -> Id {
list.with("dragging")
}
/// 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))
#[cfg(test)]
pub(crate) fn grip_id(list: impl AsId, index: usize) -> Id {
grip_of(list_id(list), index)
}
fn grip_of(list: Id, index: usize) -> Id {
list.with(("grip", 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,
list: Id,
/// Whether the rows show the model's last item first (the Design panel's paints).
reversed: bool,
/// 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
@@ -34,22 +47,35 @@ pub struct ListDrag {
}
impl ListDrag {
pub fn new(ui: &Ui, list: &'static str) -> Self {
/// A list shown topmost item (the model's last) first, as the Design panel shows paints.
pub fn new(ui: &Ui, list: impl AsId) -> Self {
Self::with_order(ui, list_id(list), true)
}
/// A list shown in model order, first item on top (an interaction's actions).
pub fn in_order(ui: &Ui, list: impl AsId) -> Self {
Self::with_order(ui, list_id(list), false)
}
fn with_order(ui: &Ui, list: Id, reversed: bool) -> 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())) {
// Escape cancels (and goes no further: it doesn't deselect or close what the list is in); a
// drag whose release this list never saw (it wasn't shown) is over.
if dragging.is_some()
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, 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 }
Self { list, reversed, 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());
let resp = ui.interact(grip, grip_of(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,
@@ -57,7 +83,10 @@ impl ListDrag {
};
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);
let icon = Rect::from_center_size(grip.center(), vec2(12.0, 12.0));
// Over the panel, hiding any guide line drawn under the rows (a conditional block's).
ui.painter().rect_filled(icon, 0.0, t.panel);
icons::paint(ui.painter(), icon, Icon::DragHandle, t.icon_secondary);
}
if resp.hovered() && self.dragging.is_none() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
@@ -77,7 +106,7 @@ impl ListDrag {
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));
let target = pos.and_then(|p| drop_slot(&self.rows, from, p, self.reversed));
if let Some((_, y)) = target
&& let Some(span) = self.span()
{
@@ -101,10 +130,10 @@ impl ListDrag {
}
}
/// 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)> {
/// Where item `from` lands when dropped at `p` over `rows` (top to bottom; the model's last item
/// first when `reversed`): 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, reversed: bool) -> 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));
@@ -123,9 +152,9 @@ fn drop_slot(rows: &[(usize, Rect)], from: usize, p: egui::Pos2) -> Option<(usiz
(None, _) => first.min.y,
(_, None) => last.max.y,
};
// Shown position once moved, then its model index (shown in reverse).
// Shown position once moved, then its model index.
let at = if gap > shown { gap - 1 } else { gap };
Some((n - 1 - at, y))
Some((if reversed { n - 1 - at } else { at }, y))
}
/// Keep per-row flags (`Id::new((salt, index))`, e.g. an open settings section) with their rows
@@ -158,18 +187,31 @@ mod tests {
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)));
assert_eq!(drop_slot(&r, 0, pos2(100.0, 4.0), true), 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)));
assert_eq!(drop_slot(&r, 0, pos2(100.0, 40.0), true), 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);
assert_eq!(drop_slot(&r, 0, pos2(100.0, 70.0), true), None);
assert_eq!(drop_slot(&r, 0, pos2(100.0, 95.0), true), None);
// The top item to the bottom: model 0.
assert_eq!(drop_slot(&r, 2, pos2(100.0, 95.0)), Some((0, 96.0)));
assert_eq!(drop_slot(&r, 2, pos2(100.0, 95.0), true), 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);
assert_eq!(drop_slot(&r, 2, pos2(100.0, 200.0), true), None);
assert_eq!(drop_slot(&r, 2, pos2(400.0, 95.0), true), None);
assert_eq!(drop_slot(&r, 7, pos2(100.0, 4.0), true), None);
assert_eq!(drop_slot(&[], 0, pos2(0.0, 0.0), true), None);
}
#[test]
fn lists_in_model_order_drop_at_the_shown_slot() {
// Shown in model order: model index k at row k.
let r: Vec<(usize, Rect)> = (0..3).map(|k| (k, Rect::from_min_size(pos2(0.0, k as f32 * 32.0), vec2(240.0, 32.0)))).collect();
// The first item below the last: model 2.
assert_eq!(drop_slot(&r, 0, pos2(100.0, 95.0), false), Some((2, 96.0)));
// The last item above the first: model 0.
assert_eq!(drop_slot(&r, 2, pos2(100.0, 4.0), false), Some((0, 0.0)));
// The first between the second and third: model 1.
assert_eq!(drop_slot(&r, 0, pos2(100.0, 70.0), false), Some((1, 64.0)));
assert_eq!(drop_slot(&r, 1, pos2(100.0, 40.0), false), None);
}
}
@@ -70,6 +70,7 @@ fn escape_cancels_a_row_drag() {
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"]);
assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag");
}
#[test]
+37 -11
View File
@@ -1,5 +1,5 @@
//! The interaction editor's actions: an interaction's action list (add, reorder, remove, open one
//! to edit it), conditional actions built in place (if / else if / else blocks, each with a
//! The interaction editor's actions: an interaction's action list (add, reorder by dragging a row's
//! grip or from its menu, remove, open one to edit it), conditional actions built in place (if / else if / else blocks, each with a
//! condition from the expression builder or typed as an expression, and actions of its own), and
//! set variable values given as expressions. Every edit runs a `proto.action.*` or
//! `proto.conditional.*` command (one undo step each).
@@ -12,8 +12,9 @@ use serde_json::{Value, json};
use crate::FigApp;
use crate::icons::{self, Icon};
use crate::list_drag::ListDrag;
use crate::proto_panel::{action_fields, action_of, action_summary, targets_for, value_json};
use crate::right::{menu_check, menu_item, menu_popup, row};
use crate::right::{menu_check, menu_item, menu_popup, row, row_rect};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets::{self, BindEvent, FIELD_H, FieldEdit, Lead, PAD};
@@ -146,16 +147,39 @@ fn nested(ui: &mut Ui, indent: f32, add: impl FnOnce(&mut Ui)) -> Rect {
ui.scope_builder(egui::UiBuilder::new().max_rect(max).layout(egui::Layout::top_down(egui::Align::Min)), add).response.rect
}
/// The drag-to-reorder id of the action list at `prefix` (tests find its rows' grips by it).
pub(crate) fn list_key(node: NodeId, k: usize, prefix: &[usize]) -> (&'static str, NodeId, usize, &[usize]) {
("proto_actions", node, k, prefix)
}
/// An action list: the interaction's (`prefix` empty) or a conditional block's (`prefix` =
/// `[action, block, …]`), then "Add action".
/// `[action, block, …]`), then "Add action". Rows drag by their grip to reorder the list.
pub fn action_list(app: &mut FigApp, ui: &mut Ui, cx: &Cx, list: &[Action], prefix: &[usize]) {
let open = open_path(ui.ctx(), cx);
// An interaction with one action shows its editor as it is.
let single = prefix.is_empty() && list.len() == 1;
let mut drag = (!single).then(|| ListDrag::in_order(ui, list_key(cx.node, cx.k, prefix)));
for (i, a) in list.iter().enumerate() {
let path = [prefix, &[i]].concat();
let is_open = single || open.starts_with(&path);
action_item(app, ui, cx, a, path, list.len(), is_open, single);
let (header, extent) = action_item(app, ui, cx, a, path, list.len(), is_open, single);
if let Some(d) = &mut drag {
d.row(ui, i, header, extent, || action_summary(cx.doc, a));
}
}
if let Some((from, to)) = drag.and_then(|d| d.finish(ui)) {
let moved = cx.slot([prefix, &[from]].concat());
if run_at(app, &moved, "proto.action.move", json!({ "to": to })).is_some() {
// The open action (or the one inside it) stays open where it went.
let mut next = open;
if next.len() > prefix.len()
&& next.starts_with(prefix)
&& let Some(at) = next.get_mut(prefix.len())
{
*at = figcraft_engine::cmd::lists::moved_index(*at, from, to);
set_open(ui.ctx(), cx, next);
}
}
}
add_action_row(app, ui, cx, [prefix, &[list.len()]].concat());
}
@@ -194,14 +218,14 @@ fn add_action_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, path: Vec<usize>) {
}
/// One action: its kind (open) or a summary (closed) with a menu to move or remove it, and when
/// open its settings below.
/// open its settings below. Returns its row and the area it covers with its settings.
#[allow(clippy::too_many_arguments)]
fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec<usize>, count: usize, open: bool, single: bool) {
fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec<usize>, count: usize, open: bool, single: bool) -> (Rect, Rect) {
let t = Tokens::get(ui.ctx());
let i = path.last().copied().unwrap_or(0);
let removable = path.len() > 1 || count > 1;
let slot = cx.slot(path);
row(ui, 32.0, |ui| {
let header = row_rect(ui, 32.0, |ui| {
if !single {
let (r, resp) = ui.allocate_exact_size(vec2(12.0, FIELD_H), Sense::click());
let resp = resp.on_hover_text(if open { "Close" } else { "Open" });
@@ -281,6 +305,7 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec<usi
other => action_fields(app, ui, cx.doc, cx.page, &slot, other),
});
}
(header, header.with_max_y(ui.cursor().min.y.max(header.max.y)))
}
/// The if / else if / else blocks of a conditional action: each one's condition and actions,
@@ -298,10 +323,11 @@ fn conditional_blocks(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, block
condition_editor(app, ui, cx, slot, b, c);
}
let prefix = [slot.path.as_slice(), &[b]].concat();
// A line down the block's actions, under its label (and under their rows' drag handles).
let line = ui.painter().add(egui::Shape::Noop);
let r = nested(ui, INDENT, |ui| action_list(app, ui, cx, &block.actions, &prefix));
// A line down the block's actions, under its label.
let x = r.min.x - INDENT + PAD + 2.0;
ui.painter().vline(x, r.min.y + 2.0..=r.max.y - 4.0, Stroke::new(1.0, t.border));
ui.painter().set(line, egui::Shape::vline(x, r.min.y + 2.0..=r.max.y - 4.0, Stroke::new(1.0, t.border)));
}
let has_else = blocks.last().is_some_and(|b| b.condition.is_none());
row(ui, 28.0, |ui| {
@@ -382,7 +408,7 @@ pub fn simple(e: &Expr) -> Option<Simple> {
let rhs = match rhs {
Expr::Value(VariableValue::Alias(w)) => Rhs::Variable(w.clone()),
Expr::Value(x) => Rhs::Value(x.clone()),
Expr::Op { .. } => return None,
_ => return None,
};
Some(Simple { var: v.clone(), op: *op, rhs })
}
+140
View File
@@ -0,0 +1,140 @@
//! Dragging actions in the interaction editor, driven headless through egui: a row dropped
//! elsewhere in its list moves the action in one undo step (top level and inside a conditional
//! block), the open action stays open where it went, and Escape cancels the drag without closing
//! the editor.
use egui::{Event, Pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_doc::proto::Action;
use figcraft_engine::Session;
use serde_json::json;
use crate::list_drag::grip_id;
use crate::present_tests::{Ui, id};
use crate::proto_editor::list_key;
/// A button on frame A whose click runs: navigate to B, a conditional (if count >= 3: navigate to
/// B, add one to count, back), back. The interaction editor is open on it with the conditional open.
fn setup() -> (Ui, NodeId) {
let mut s = Session::new();
let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300, "name": "A"})).unwrap());
let b = id(&s.execute("frame.create", &json!({"x": 500, "y": 0, "width": 400, "height": 300, "name": "B"})).unwrap());
let btn = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 120, "height": 40, "parent": a})).unwrap());
s.execute("variable.createCollection", &json!({"name": "State"})).unwrap();
s.execute("variable.create", &json!({"collection": "State", "name": "count", "type": "number", "value": 0})).unwrap();
s.execute("proto.interaction.add", &json!({"id": btn, "dest": b})).unwrap();
s.execute(
"proto.action.add",
&json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [
{"expression": "count >= 3", "actions": [
{"type": "navigate", "dest": b},
{"type": "setVariable", "variable": "count", "expression": "count + 1"},
{"type": "back"},
]},
]}}),
)
.unwrap();
s.execute("proto.action.add", &json!({"id": btn, "index": 0, "action": {"type": "back"}})).unwrap();
s.execute("select.set", &json!({"ids": [btn]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.ctx.data_mut(|d| d.insert_temp(open_id(btn), vec![1usize]));
u.frames(3, Vec::new);
(u, btn)
}
fn open_id(btn: NodeId) -> egui::Id {
egui::Id::new(("proto_open_action", btn, 0usize))
}
/// The kinds of the actions at `prefix` (the interaction's, or a block's).
fn kinds(u: &Ui, btn: NodeId, prefix: &[usize]) -> Vec<&'static str> {
let doc = u.app.session.doc().unwrap();
let actions = &doc.node(btn).unwrap().interactions[0].actions;
let list = match prefix {
[] => actions.as_slice(),
[a, b] => match &actions[*a] {
Action::Conditional { blocks } => blocks[*b].actions.as_slice(),
other => panic!("{other:?}"),
},
_ => panic!(),
};
list.iter().map(Action::label).collect()
}
/// The middle of the grip of action `index` of the list at `prefix` (as laid out last frame).
fn grip(u: &Ui, btn: NodeId, prefix: &[usize], index: usize) -> Pos2 {
u.ctx.read_response(grip_id(list_key(btn, 0, prefix), index)).expect("the row has a grip").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))]);
}
}
fn undo_len(u: &Ui) -> usize {
u.app.session.ds().unwrap().history.undo.len()
}
#[test]
fn dragging_an_action_row_reorders_the_actions_in_one_step() {
let (mut u, btn) = setup();
assert_eq!(kinds(&u, btn, &[]), ["Navigate to", "Conditional", "Back"]);
let (first, last) = (grip(&u, btn, &[], 0), grip(&u, btn, &[], 2));
assert!(first.y < last.y, "listed in order");
let undo = undo_len(&u);
// Back above the first action.
let to = first - vec2(0.0, 12.0);
drag(&mut u, last, to);
assert_eq!(kinds(&u, btn, &[]), ["Navigate to", "Conditional", "Back"], "nothing moves before the drop");
u.button(to, false);
assert_eq!(kinds(&u, btn, &[]), ["Back", "Navigate to", "Conditional"]);
assert_eq!(undo_len(&u), undo + 1);
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
// The conditional stays open where it went.
assert_eq!(u.ctx.data(|d| d.get_temp::<Vec<usize>>(open_id(btn))), Some(vec![2]));
// Dropped where it is: nothing changes.
u.frames(2, Vec::new);
let g = grip(&u, btn, &[], 1);
drag(&mut u, g, g + vec2(0.0, 6.0));
u.button(g + vec2(0.0, 6.0), false);
assert_eq!(undo_len(&u), undo + 1);
// Undo puts the order back.
assert!(u.app.session.undo().unwrap());
assert_eq!(kinds(&u, btn, &[]), ["Navigate to", "Conditional", "Back"]);
}
#[test]
fn actions_inside_a_conditional_block_drag_within_their_block() {
let (mut u, btn) = setup();
let block = [1usize, 0];
assert_eq!(kinds(&u, btn, &block), ["Navigate to", "Set variable", "Back"]);
// Open the set variable action inside the block.
u.ctx.data_mut(|d| d.insert_temp(open_id(btn), vec![1usize, 0, 1]));
u.frames(3, Vec::new);
let (first, last) = (grip(&u, btn, &block, 0), grip(&u, btn, &block, 2));
let undo = undo_len(&u);
// The first action below the last one.
let to = last + vec2(0.0, 12.0);
drag(&mut u, first, to);
u.button(to, false);
assert_eq!(kinds(&u, btn, &block), ["Set variable", "Back", "Navigate to"]);
assert_eq!(kinds(&u, btn, &[]), ["Navigate to", "Conditional", "Back"], "the outer list stays");
assert_eq!(undo_len(&u), undo + 1);
assert_eq!(u.ctx.data(|d| d.get_temp::<Vec<usize>>(open_id(btn))), Some(vec![1, 0, 0]), "the open action follows");
}
#[test]
fn escape_cancels_an_action_drag_and_keeps_the_editor_open() {
let (mut u, btn) = setup();
let (first, last) = (grip(&u, btn, &[], 0), grip(&u, btn, &[], 2));
drag(&mut u, last, first);
u.key(first, egui::Key::Escape);
u.button(first, false);
assert_eq!(kinds(&u, btn, &[]), ["Navigate to", "Conditional", "Back"]);
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)), "the editor stays open");
}
+10 -3
View File
@@ -223,11 +223,17 @@ names; modes chosen on layers inside the screen still win); `{type: "conditional
"else"). Expressions are `{value: {type: number|string|boolean|color|alias, value}}` (`alias` reads
a variable) or `{op: {op, args}}` with `add` (text joins when a string is involved), `subtract`,
`multiply`, `divide`, `equals`, `notEqual`, `less`, `lessOrEqual`, `greater`, `greaterOrEqual`,
`and`, `or`, `not` and `negate`. Expressions can also be written as text: `expression: "count + 1"`
`and`, `or`, `not` and `negate`; `{inMode: {variable, mode}}` reads a variable's value in one mode
of its collection whichever mode is chosen, and `{mode: {collection}}` gives the name of the mode a
collection is in where the interaction runs (as text; it follows set variable mode actions).
Variables, collections and modes may be given by name; they are stored by id. Expressions can also
be written as text: `expression: "count + 1"`
on a set variable action, or `expression: "count >= 3 and not done"` on a conditional block, in
place of `expr` / `condition` (variables by name, `{name}` for any name; numbers, `"text"`,
`true` / `false`, `#RRGGBB`; `or` / `||`, `and` / `&&`, `not` / `!`, `==` (or `=`), `!=`, `<`,
`<=`, `>`, `>=`, `+` (joins text), `-`, `*`, `/` and brackets). Text expressions are checked: a
`<=`, `>`, `>=`, `+` (joins text), `-`, `*`, `/` and brackets; `accent[Dark]` for a variable's
value in a mode, `mode(Theme) == "Dark"` for a collection's current mode, names with brackets or
spaces at their ends in quotes). Text expressions are checked: a
condition must give true or false and a set variable's expression a value the variable can hold,
otherwise the command fails with what's wrong (`Something is missing at the end`, `No variable
\`x\``, `Can't compare a number with text`). Single actions, also inside conditionals, are edited
@@ -400,7 +406,8 @@ The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`,
`run_command {command: "file.import", params: {path}}` (pages appended to the current document,
with their flows, prototype settings and variables).
The result's `warnings` list what was skipped or approximated (unsupported layer, paint and effect
types, video and gamepad triggers, variable mode lookups in expressions, bindings to library
types, video and gamepad triggers, expression functions other than a variable mode lookup over a
variable and one of its modes, bindings to library
variables, instances of components outside the file, which come in as frames). Pages, the layer
tree, geometry, paints, strokes, effects, corners, constraints, auto layout, layout grids, exports,
text with styled runs, vectors, components, component sets, instances and component properties are