mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
+14
-11
@@ -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 |
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -438,7 +438,7 @@ pub struct Node {
|
||||
pub annotations: Vec<Annotation>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
pub(crate) fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn one() -> f32 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user