mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
M5: prototyping: delays on mouse triggers, manual overlay offsets
This commit is contained in:
@@ -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 (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|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}",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), 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}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
|
||||
true,
|
||||
add
|
||||
),
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.interaction.set",
|
||||
"Edit interaction",
|
||||
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?}",
|
||||
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
|
||||
true,
|
||||
set
|
||||
),
|
||||
@@ -358,6 +358,32 @@ fn duration(p: &Value) -> Option<u32> {
|
||||
num(p, "durationMs").or_else(|| num(p, "duration_ms")).map(|d| d.clamp(0.0, 60_000.0) as u32)
|
||||
}
|
||||
|
||||
/// The delay of a mouse enter, leave, down or up trigger (`delayMs`), capped.
|
||||
fn delay(cmd: &str, p: &Value) -> Result<Option<u32>> {
|
||||
match get(p, "delayMs").or_else(|| get(p, "delay_ms")) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match f64_of(v).filter(|d| d.is_finite() && *d >= 0.0) {
|
||||
Some(d) => Ok(Some(d.min(f64::from(MAX_DELAY_MS)).round() as u32)),
|
||||
None => Err(bad(cmd, "delayMs: milliseconds, 0 or more")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A delay only where it applies (mouse enter, leave, down and up), and a manual offset only on a
|
||||
/// manual overlay, within what the player places.
|
||||
fn settle(it: &mut Interaction) {
|
||||
if it.delay_secs() == 0.0 {
|
||||
it.delay_ms = 0;
|
||||
}
|
||||
it.delay_ms = it.delay_ms.min(MAX_DELAY_MS);
|
||||
walk_mut(&mut it.actions, &mut |a| {
|
||||
if let Action::OpenOverlay { position, offset, .. } = a {
|
||||
*offset =
|
||||
offset.filter(|o| *position == OverlayPosition::Manual && o.iter().all(|c| c.is_finite())).map(|o| o.map(|c| c.clamp(-1e5, 1e5)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "proto.interaction.add";
|
||||
let p = &super::proto_actions::with_expressions(cmd, &s.doc()?.variables, p)?;
|
||||
@@ -370,6 +396,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
transition: parse(cmd, p, "transition")?.unwrap_or_default(),
|
||||
duration_ms: duration(p).unwrap_or(300),
|
||||
easing: parse(cmd, p, "easing")?.unwrap_or_default(),
|
||||
delay_ms: 0,
|
||||
}
|
||||
} else {
|
||||
let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default();
|
||||
@@ -379,9 +406,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let transition: Transition = parse(cmd, p, "transition")?.unwrap_or_default();
|
||||
let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default();
|
||||
Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing }
|
||||
Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing, delay_ms: 0 }
|
||||
};
|
||||
let mut it = it;
|
||||
it.delay_ms = delay(cmd, p)?.unwrap_or(0);
|
||||
settle(&mut it);
|
||||
canonical_ids(s.doc()?, &mut it);
|
||||
check_dests(cmd, s, &it)?;
|
||||
let mut index = 0;
|
||||
@@ -408,6 +437,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let transition: Option<Transition> = parse(cmd, p, "transition")?;
|
||||
let easing: Option<Easing> = parse(cmd, p, "easing")?;
|
||||
let dur = duration(p);
|
||||
let wait = delay(cmd, p)?;
|
||||
let mut hit = false;
|
||||
for i in req_targets(cmd, s, p)? {
|
||||
let Some(cur) = s.doc()?.get(i)?.interactions.get(k).cloned() else { continue };
|
||||
@@ -433,6 +463,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = dur {
|
||||
it.duration_ms = d;
|
||||
}
|
||||
if let Some(d) = wait {
|
||||
it.delay_ms = d;
|
||||
}
|
||||
settle(&mut it);
|
||||
if it.actions.is_empty() {
|
||||
return Err(bad(cmd, "an interaction needs an action"));
|
||||
}
|
||||
|
||||
@@ -1466,6 +1466,8 @@ mod proto_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_conditional_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_delay_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_multi_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_polish_tests;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Delays on mouse triggers and manual overlay offsets through commands: `delayMs` and the open
|
||||
//! overlay action's `offset`, what they apply to, undo, the REST and `.fig` mappings, junk values.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::params::id_of;
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::proto::{Action, Interaction, OverlayPosition, Trigger};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
|
||||
fn frame(s: &mut Session, x: f64) -> NodeId {
|
||||
id_of(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 100, "height": 200})).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn first(s: &Session, id: NodeId) -> Interaction {
|
||||
s.doc().unwrap().node(id).unwrap().interactions[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delays_belong_to_mouse_triggers() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = (frame(&mut s, 0.0), frame(&mut s, 200.0));
|
||||
let add = |s: &mut Session, trigger: &str, delay: Value| {
|
||||
s.execute("proto.interaction.clear", &json!({"ids": [a]})).unwrap();
|
||||
s.execute(
|
||||
"proto.interaction.add",
|
||||
&json!({"id": a, "trigger": {"type": trigger}, "action": {"type": "navigate", "dest": b}, "delayMs": delay}),
|
||||
)
|
||||
.unwrap();
|
||||
first(s, a).delay_ms
|
||||
};
|
||||
// (trigger, delay asked for, delay kept, why)
|
||||
for (trigger, asked, kept, why) in [
|
||||
("mouseEnter", json!(300), 300, "mouse enter waits"),
|
||||
("mouseLeave", json!(300), 300, "mouse leave waits"),
|
||||
("mouseDown", json!(1), 1, "mouse down waits"),
|
||||
("mouseUp", json!(250.4), 250, "whole milliseconds"),
|
||||
("mouseUp", json!(9e9), 600_000, "ten minutes at most"),
|
||||
("onClick", json!(300), 0, "a click has no delay"),
|
||||
("onHover", json!(300), 0, "while hovering has none"),
|
||||
("onDrag", json!(300), 0, "a drag has none"),
|
||||
] {
|
||||
assert_eq!(add(&mut s, trigger, asked.clone()), kept, "{trigger} {asked}: {why}");
|
||||
}
|
||||
// Patched, then lost with a trigger it doesn't apply to; each edit is one undo step.
|
||||
add(&mut s, "mouseEnter", json!(0));
|
||||
s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "delayMs": 450})).unwrap();
|
||||
assert_eq!(first(&s, a).delay_ms, 450);
|
||||
s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "trigger": {"type": "onClick"}})).unwrap();
|
||||
assert_eq!(first(&s, a).delay_ms, 0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((first(&s, a).trigger, first(&s, a).delay_ms), (Trigger::MouseEnter, 450));
|
||||
// Stored without the field when there is none, so older files read the same.
|
||||
let json = serde_json::to_value(first(&s, a)).unwrap();
|
||||
assert_eq!(json["delay_ms"], 450);
|
||||
s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "delayMs": 0})).unwrap();
|
||||
assert!(serde_json::to_value(first(&s, a)).unwrap().get("delay_ms").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offset_belongs_to_a_manual_overlay() {
|
||||
let mut s = Session::new();
|
||||
let (a, menu) = (frame(&mut s, 0.0), frame(&mut s, 200.0));
|
||||
let open = |s: &mut Session, position: &str, offset: Value| {
|
||||
s.execute("proto.interaction.clear", &json!({"ids": [a]})).unwrap();
|
||||
let action = json!({"type": "openOverlay", "dest": menu, "position": position, "offset": offset});
|
||||
s.execute("proto.interaction.add", &json!({"id": a, "action": action})).unwrap();
|
||||
match first(s, a).actions.remove(0) {
|
||||
Action::OpenOverlay { offset, .. } => offset,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(open(&mut s, "manual", json!([12, -8])), Some([12.0, -8.0]));
|
||||
assert_eq!(open(&mut s, "manual", json!(null)), None, "none: right below the layer that opens it");
|
||||
assert_eq!(open(&mut s, "manual", json!([1e12, -1e12])), Some([1e5, -1e5]), "kept within reach");
|
||||
assert_eq!(open(&mut s, "center", json!([12, -8])), None, "only a manual overlay has one");
|
||||
// A file from before the field reads as no offset.
|
||||
let old: Action = serde_json::from_value(json!({"type": "openOverlay", "dest": menu, "position": "manual"})).unwrap();
|
||||
assert!(matches!(old, Action::OpenOverlay { offset: None, position: OverlayPosition::Manual, .. }));
|
||||
}
|
||||
|
||||
fn rest(trigger: Value, action: Value) -> Document {
|
||||
let at = |x: f64| json!([[1, 0, x], [0, 1, 0]]);
|
||||
let file = json!({"name": "File", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
{"id": "1:1", "name": "A", "type": "FRAME", "relativeTransform": at(0.0), "size": {"x": 100, "y": 100}, "children": [
|
||||
{"id": "1:2", "name": "Hot", "type": "RECTANGLE", "relativeTransform": at(10.0), "size": {"x": 20, "y": 20},
|
||||
"interactions": [{"trigger": trigger, "actions": [action]}]}]},
|
||||
{"id": "1:3", "name": "Menu", "type": "FRAME", "relativeTransform": at(200.0), "size": {"x": 50, "y": 50}, "overlayPositionType": "MANUAL", "children": []}
|
||||
]}]}});
|
||||
let (doc, warnings) = figcraft_format::rest::import(&file, &figcraft_format::rest::no_images).unwrap();
|
||||
assert!(warnings.iter().all(|w| !w.contains("delay")), "{warnings:?}");
|
||||
doc
|
||||
}
|
||||
|
||||
fn hot(doc: &Document) -> Interaction {
|
||||
doc.nodes().find(|n| n.name == "Hot").unwrap().interactions[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_and_fig_carry_delays_and_offsets() {
|
||||
let overlay = json!({"type": "NODE", "destinationId": "1:3", "navigation": "OVERLAY", "overlayRelativePosition": {"x": 12, "y": -8}});
|
||||
let doc = rest(json!({"type": "MOUSE_ENTER", "delay": 300}), overlay.clone());
|
||||
let it = hot(&doc);
|
||||
assert_eq!((it.trigger.clone(), it.delay_ms), (Trigger::MouseEnter, 300));
|
||||
assert!(matches!(it.actions[0], Action::OpenOverlay { position: OverlayPosition::Manual, offset: Some([12.0, -8.0]), .. }), "{it:?}");
|
||||
// A click has no delay to read; a manual overlay without a position sits below its layer.
|
||||
let plain = hot(&rest(json!({"type": "ON_CLICK", "delay": 300}), json!({"type": "NODE", "destinationId": "1:3", "navigation": "OVERLAY"})));
|
||||
assert_eq!(plain.delay_ms, 0);
|
||||
assert!(matches!(plain.actions[0], Action::OpenOverlay { offset: None, .. }));
|
||||
// Junk reads as nothing.
|
||||
let junk = hot(&rest(
|
||||
json!({"type": "MOUSE_UP", "delay": "soon"}),
|
||||
json!({"type": "NODE", "destinationId": "1:3", "navigation": "OVERLAY", "overlayRelativePosition": {"x": "a"}}),
|
||||
));
|
||||
assert_eq!(junk.delay_ms, 0);
|
||||
assert!(matches!(junk.actions[0], Action::OpenOverlay { offset: None, .. }));
|
||||
// Written to `.fig` and read back.
|
||||
let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap();
|
||||
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
|
||||
let again = hot(&back);
|
||||
assert_eq!((again.trigger, again.delay_ms), (Trigger::MouseEnter, 300));
|
||||
assert!(
|
||||
matches!(again.actions[0], Action::OpenOverlay { position: OverlayPosition::Manual, offset: Some([12.0, -8.0]), .. }),
|
||||
"{:?}",
|
||||
again.actions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_values_are_refused_not_fatal() {
|
||||
let junk = [json!(true), json!(-1), json!("soon"), json!([1, 2]), json!({"a": 1}), json!(f64::MAX), json!("x".repeat(10_000))];
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = (frame(&mut s, 0.0), frame(&mut s, 200.0));
|
||||
let nav = json!({"type": "navigate", "dest": b});
|
||||
let adds = [
|
||||
json!({"id": a, "trigger": {"type": "mouseEnter"}, "action": nav, "delayMs": j}),
|
||||
json!({"id": a, "action": {"type": "openOverlay", "dest": b, "position": "manual", "offset": j}}),
|
||||
json!({"id": a, "action": {"type": "openOverlay", "dest": b, "position": "manual", "offset": [j, j]}}),
|
||||
];
|
||||
for p in adds {
|
||||
let r = s.execute("proto.interaction.add", &p);
|
||||
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{p}: {r:?}");
|
||||
}
|
||||
s.execute("proto.interaction.add", &json!({"id": b, "trigger": {"type": "mouseUp"}, "action": {"type": "back"}})).unwrap();
|
||||
let r = s.execute("proto.interaction.set", &json!({"id": b, "index": 0, "delayMs": j}));
|
||||
assert!(!matches!(r, Err(EngineError::Internal { .. })), "set {j}: {r:?}");
|
||||
for n in [a, b] {
|
||||
for it in &s.doc().unwrap().node(n).unwrap().interactions {
|
||||
assert!(it.delay_ms <= 600_000, "{j}: {it:?}");
|
||||
if let Action::OpenOverlay { offset: Some(o), .. } = &it.actions[0] {
|
||||
assert!(o.iter().all(|c| c.is_finite() && c.abs() <= 1e5), "{j}: {o:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user