From 5548284c65f85da2850acbec08a726c5f160271c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 01:27:22 +0700 Subject: [PATCH] M5: prototyping: delays on mouse triggers, manual overlay offsets --- ROADMAP.md | 4 +- crates/doc/src/colors_tests.rs | 9 +- crates/doc/src/proto.rs | 25 +++ crates/doc/src/tests.rs | 1 + crates/engine/src/cmd/proto.rs | 40 ++++- crates/engine/src/lib.rs | 2 + crates/engine/src/proto_delay_tests.rs | 157 +++++++++++++++++ crates/format/src/fig/export.rs | 16 +- crates/format/src/fig/export_tests.rs | 5 +- crates/format/src/fig/map.rs | 3 + crates/format/src/rest/proto.rs | 37 ++-- .../format/src/rest/proto_variable_tests.rs | 2 +- crates/ui-egui/src/present.rs | 93 ++++++++-- crates/ui-egui/src/present_behaviour_tests.rs | 2 +- crates/ui-egui/src/present_delay_tests.rs | 162 ++++++++++++++++++ crates/ui-egui/src/present_variables_tests.rs | 3 +- crates/ui-egui/src/proto_panel.rs | 57 +++++- docs/control-protocol.md | 2 +- docs/mcp.md | 4 +- docs/prototyping-parity.md | 8 +- docs/target-app-parity.md | 5 +- 21 files changed, 583 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/proto_delay_tests.rs create mode 100644 crates/ui-egui/src/present_delay_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index f5e833a..12a206a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. - **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. - **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as @@ -298,6 +299,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged | diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index 0d37fe1..d20a757 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -22,10 +22,17 @@ fn colourful() -> (Document, NodeId) { n.grids = vec![LayoutGrid { color: RED, ..LayoutGrid::default() }]; n.interactions = vec![proto::Interaction { trigger: Default::default(), - actions: vec![proto::Action::OpenOverlay { dest: p, position: Default::default(), close_on_outside: false, background: Some(RED) }], + actions: vec![proto::Action::OpenOverlay { + dest: p, + position: Default::default(), + close_on_outside: false, + background: Some(RED), + offset: None, + }], transition: Default::default(), duration_ms: 300, easing: Default::default(), + delay_ms: 0, }]; } if let Some(NodeKind::Text(td)) = d.node_mut(t).map(|n| &mut n.kind) { diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 234f607..91d2e8e 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -534,6 +534,10 @@ pub enum Action { /// Colour drawn behind the overlay (over the frame underneath); None = no background. #[serde(default)] background: Option, + /// Manual position: where the overlay's top-left sits from the top-left of the layer that + /// opened it (x, y). None = right below that layer. + #[serde(default, skip_serializing_if = "Option::is_none")] + offset: Option<[f64; 2]>, }, SwapOverlay { dest: NodeId, @@ -709,12 +713,33 @@ pub struct Interaction { pub duration_ms: u32, #[serde(default)] pub easing: Easing, + /// Mouse enter, leave, down and up: how long after the gesture the actions run (ms). + #[serde(default, alias = "delayMs", skip_serializing_if = "is_zero")] + pub delay_ms: u32, } +impl Interaction { + /// Seconds between the gesture and the actions: the delay of a mouse enter, leave, down or up + /// trigger (other triggers have none; "after delay" times itself). + pub fn delay_secs(&self) -> f64 { + match self.trigger { + Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => f64::from(self.delay_ms.min(MAX_DELAY_MS)) / 1000.0, + _ => 0.0, + } + } +} + +/// Longest delay a trigger waits (ms). +pub const MAX_DELAY_MS: u32 = 600_000; + fn default_duration() -> u32 { 300 } +fn is_zero(v: &u32) -> bool { + *v == 0 +} + fn is_false(b: &bool) -> bool { !*b } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index f050b95..13d756a 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -97,6 +97,7 @@ fn duplicate_remaps_ids_and_interactions() { transition: proto::Transition::Instant, duration_ms: 300, easing: proto::Easing::EaseOut, + delay_ms: 0, }); let f2 = d.duplicate_into(f, p, None).unwrap(); let kids = d.node(f2).unwrap().children.clone(); diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index a42c598..be6907b 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { 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 { 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 { 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> { + 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 { 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 { 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 { }; 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 { let transition: Option = parse(cmd, p, "transition")?; let easing: Option = 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 { 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")); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fd9936f..823509c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/proto_delay_tests.rs b/crates/engine/src/proto_delay_tests.rs new file mode 100644 index 0000000..dd6072b --- /dev/null +++ b/crates/engine/src/proto_delay_tests.rs @@ -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:?}"); + } + } + } + } +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 46d9007..09cf5bf 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -162,7 +162,7 @@ message VariableDataValuesEntry GUID modeID=1, VariableData variableData=2 message VariableDataValues VariableDataValuesEntry[] entries=1 message KeyTrigger int[] keyCodes=1, TriggerDevice triggerDevice=2 message PrototypeEvent InteractionType interactionType=1, float transitionTimeout=6, KeyTrigger keyTrigger=4 -message PrototypeAction GUID transitionNodeID=1, TransitionType transitionType=2, float transitionDuration=3, EasingType easingType=4, float[] easingFunction=12, ConnectionType connectionType=7, NavigationType navigationType=10, string connectionURL=8, bool transitionPreserveScroll=11 +message PrototypeAction GUID transitionNodeID=1, TransitionType transitionType=2, float transitionDuration=3, EasingType easingType=4, float[] easingFunction=12, ConnectionType connectionType=7, Vector overlayRelativePosition=9, NavigationType navigationType=10, string connectionURL=8, bool transitionPreserveScroll=11 message PrototypeInteraction PrototypeEvent event=2, PrototypeAction[] actions=3 message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 @@ -538,7 +538,7 @@ impl<'d> Exporter<'d> { _ => {} } for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) { - if let Action::OpenOverlay { dest, position, close_on_outside, background } = a { + if let Action::OpenOverlay { dest, position, close_on_outside, background, .. } = a { let o = Overlay { position: *position, close_on_outside: *close_on_outside, background: *background }; match self.overlays.get(dest) { Some(old) if (old.position, old.close_on_outside, old.background) != (o.position, o.close_on_outside, o.background) => { @@ -2055,6 +2055,10 @@ impl<'d> Exporter<'d> { "ON_KEY_DOWN" } }; + // A mouse trigger's delay is the event's timeout. + if it.delay_secs() > 0.0 { + event.push(("transitionTimeout", fl(it.delay_secs()))); + } event.insert(0, ("interactionType", e(ty))); let actions: Vec = it.actions.iter().filter_map(|a| self.action(a, it)).collect(); (!actions.is_empty()).then(|| o(vec![("event", o(event)), ("actions", arr(actions))])) @@ -2079,7 +2083,13 @@ impl<'d> Exporter<'d> { } Some(act) } - Action::OpenOverlay { dest, .. } => node(self, dest, "OVERLAY"), + Action::OpenOverlay { dest, position, offset, .. } => { + let mut act = node(self, dest, "OVERLAY")?; + if let (Kv::Object(f), OverlayPosition::Manual, Some([x, y])) = (&mut act, position, offset) { + f.push(("overlayRelativePosition", o(vec![("x", fl(*x)), ("y", fl(*y))]))); + } + Some(act) + } Action::SwapOverlay { dest } => node(self, dest, "SWAP"), Action::ChangeTo { dest } => node(self, dest, "SWAP_STATE"), Action::ScrollTo { dest, offset } => { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index f9bf281..8d66b72 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -510,6 +510,7 @@ impl Gen { [(dest.0 % 4) as usize], close_on_outside: true, background: Some(Color::new(0.0, 0.0, 0.0, 0.5)), + offset: None, }, 3 => Action::SwapOverlay { dest }, 4 => Action::ChangeTo { dest }, @@ -539,7 +540,8 @@ impl Gen { Easing::Gentle, Easing::CustomBezier { x1: 0.25, y1: 0.125, x2: 0.5, y2: 1.0 }, ]); - let it = Interaction { trigger, actions: vec![action], transition, duration_ms: 10 * (1 + self.r.below(100) as u32), easing }; + let it = + Interaction { trigger, actions: vec![action], transition, duration_ms: 10 * (1 + self.r.below(100) as u32), easing, delay_ms: 0 }; let fixed = self.r.chance(20); let n = self.doc.node_mut(id).unwrap(); n.interactions.push(it); @@ -984,6 +986,7 @@ fn junk_documents_never_panic() { transition: Transition::Instant, duration_ms: u32::MAX, easing: Easing::default(), + delay_ms: u32::MAX, }); match &mut n.kind { NodeKind::Vector { network } => { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 9074c4e..787ca6d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1017,6 +1017,9 @@ impl<'a> Mapper<'a> { if let Some(b) = a.get("transitionPreserveScroll").and_then(Kv::bool) { act["preserveScrollPosition"] = json!(b); } + if let Some(p) = a.get("overlayRelativePosition") { + act["overlayRelativePosition"] = json!({"x": num(p, "x"), "y": num(p, "y")}); + } act } Some("URL") => json!({"type": "URL", "url": a.get("connectionURL").and_then(Kv::str)?}), diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 91c3e4c..56e50e5 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -13,6 +13,8 @@ use super::Ctx; use super::props::{color, flag, list, num, text}; use super::variables; +/// Farthest a manual overlay sits from the layer that opens it (page units, each way). +const MAX_OFFSET: f64 = 100_000.0; /// Longest transition or delay kept (ms). const MAX_MS: f64 = 60_000.0; /// Most blocks of one conditional and arguments of one expression function read. @@ -135,11 +137,15 @@ impl Ctx<'_> { transition: if ms > 0 { Transition::Dissolve } else { Transition::Instant }, duration_ms: if ms > 0 { ms } else { DEFAULT_MS }, easing: easing_type(text(j, "transitionEasing"), None), + delay_ms: 0, }) } fn interaction(&mut self, it: &Value) -> Option { - let trigger = self.trigger(it.get("trigger").filter(|t| t.is_object())?)?; + let t = it.get("trigger").filter(|t| t.is_object())?; + let trigger = self.trigger(t)?; + // Mouse enter, leave, down and up wait before they act (`.fig` files name it `timeout`). + let delay_ms = num(t, "delay").or_else(|| num(t, "timeout")).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, MAX_MS) as u32); let mut actions = Vec::new(); let mut transition: Option<&Value> = None; for a in list(it, "actions") { @@ -153,26 +159,24 @@ impl Ctx<'_> { Some(t) => self.transition(t), None => (Transition::Instant, DEFAULT_MS, Easing::default()), }; - Some(Interaction { trigger, actions, transition, duration_ms, easing }) + let mut it = Interaction { trigger, actions, transition, duration_ms, easing, delay_ms }; + if it.delay_secs() == 0.0 { + it.delay_ms = 0; + } + Some(it) } fn trigger(&mut self, t: &Value) -> Option { - let mouse = |cx: &mut Self, tr: Trigger| { - if num(t, "delay").is_some_and(|d| d > 0.0) { - cx.warn("delays on mouse enter/leave/down/up triggers are not supported and were dropped"); - } - Some(tr) - }; match text(t, "type") { Some("ON_CLICK") => Some(Trigger::OnClick), Some("ON_HOVER") => Some(Trigger::OnHover), Some("ON_PRESS") => Some(Trigger::OnPress), Some("ON_DRAG") => Some(Trigger::OnDrag), Some("AFTER_TIMEOUT") => Some(Trigger::AfterDelay { ms: num(t, "timeout").unwrap_or(0.0).clamp(0.0, MAX_MS) as u32 }), - Some("MOUSE_ENTER") => mouse(self, Trigger::MouseEnter), - Some("MOUSE_LEAVE") => mouse(self, Trigger::MouseLeave), - Some("MOUSE_DOWN") => mouse(self, Trigger::MouseDown), - Some("MOUSE_UP") => mouse(self, Trigger::MouseUp), + Some("MOUSE_ENTER") => Some(Trigger::MouseEnter), + Some("MOUSE_LEAVE") => Some(Trigger::MouseLeave), + Some("MOUSE_DOWN") => Some(Trigger::MouseDown), + Some("MOUSE_UP") => Some(Trigger::MouseUp), Some(ty @ ("ON_KEY_DOWN" | "ON_KEY_UP")) => { let codes: Vec = list(t, "keyCodes").iter().filter_map(Value::as_f64).collect(); // Controllers: their codes read as standard-layout button indices; one button each. @@ -311,7 +315,14 @@ impl Ctx<'_> { .filter(|b| text(b, "type") == Some("SOLID_COLOR")) .and_then(|b| b.get("color")) .and_then(color); - Action::OpenOverlay { dest, position, close_on_outside, background } + // A manual overlay sits that far from the top-left of the layer that opens it. + let offset = a + .get("overlayRelativePosition") + .filter(|_| position == OverlayPosition::Manual) + .and_then(|v| Some([num(v, "x")?, num(v, "y")?])) + .filter(|o| o.iter().all(|c| c.is_finite())) + .map(|o| o.map(|c| c.clamp(-MAX_OFFSET, MAX_OFFSET))); + Action::OpenOverlay { dest, position, close_on_outside, background, offset } } Some(_) => { self.warn("prototype navigations of unknown types were skipped"); diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs index 5804953..cbaa374 100644 --- a/crates/format/src/rest/proto_variable_tests.rs +++ b/crates/format/src/rest/proto_variable_tests.rs @@ -222,7 +222,7 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!(its[1].trigger, Trigger::AfterDelay { ms: 800 }); assert_eq!(its[1].transition, Transition::MoveIn { direction: Direction::Right }); assert_eq!(its[1].easing, Easing::CustomSpring { mass: 1.0, stiffness: 200.0, damping: 20.0 }); - let Action::OpenOverlay { dest, position, close_on_outside, background } = &its[1].actions[0] else { panic!("{:?}", its[1]) }; + let Action::OpenOverlay { dest, position, close_on_outside, background, .. } = &its[1].actions[0] else { panic!("{:?}", its[1]) }; assert_eq!((*dest, *position, *close_on_outside), (id("Menu"), OverlayPosition::TopRight, false)); assert!(background.is_some_and(|b| (b.a - 0.4).abs() < 1e-3)); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 65e835a..b5f177a 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -38,6 +38,17 @@ use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets; +/// A mouse trigger waiting out its delay: interaction `k` of `node`, hit at `rect` while `root` +/// (the screen or the top overlay) showed; due at `due` on the player's clock. +#[derive(Clone, Copy, Debug)] +struct Waiting { + node: NodeId, + k: usize, + rect: Option, + root: NodeId, + due: f64, +} + /// A page in the player's history (what Back returns to, and how). #[derive(Clone, Debug)] pub struct Visit { @@ -56,6 +67,8 @@ pub struct OverlayState { pub background: Option, /// Screen rect of the layer that opened it (manual position). pub anchor: Option, + /// Manual position: the overlay's top-left from the anchor's (page units). + pub offset: Option<[f64; 2]>, opened: f64, transition: Transition, dur: f64, @@ -259,6 +272,8 @@ pub struct Presenter { textures: HashMap, arrived: f64, delays_fired: HashSet<(NodeId, usize)>, + /// Mouse triggers waiting out their delay. + waiting: Vec, hovered: Vec, press: Option, dragged: bool, @@ -335,6 +350,7 @@ impl Presenter { textures: HashMap::new(), arrived: -1.0, delays_fired: HashSet::new(), + waiting: Vec::new(), hovered: Vec::new(), press: None, dragged: false, @@ -572,6 +588,7 @@ impl Presenter { "hotspotHints": self.view.hotspot_hints, "hotspots": self.hot.iter().map(|(id, r)| json!({"id": id, "rect": [r.min.x, r.min.y, r.max.x, r.max.y]})).collect::>(), "hotspotFlash": self.flash_until > self.clock, + "waiting": self.waiting.len(), "inspect": self.view.inspect, "inspected": self.inspected, "deviceColor": doc.and_then(|d| page_settings(d, self.current).device_color()).map(|c| c.name), @@ -612,6 +629,7 @@ impl Presenter { } self.arrived = now; self.delays_fired.clear(); + self.waiting.clear(); self.hovered.clear(); self.rev += 1; } @@ -673,6 +691,34 @@ impl Presenter { self.rev += 1; } + /// A mouse enter, leave, down or up on `node` (shown on `root`) met its interaction `k`: its + /// actions run now, or after its delay while `root` is still what shows (again once only + /// while it waits). + fn gesture(&mut self, node: NodeId, k: usize, rect: Option, now: f64, root: NodeId) -> bool { + let delay = self.doc.as_ref().and_then(|d| d.node(node)).and_then(|n| n.interactions.get(k)).map_or(0.0, |it| it.delay_secs()); + if delay <= 0.0 { + return self.fire(node, k, rect, now); + } + if !self.waiting.iter().any(|w| (w.node, w.k) == (node, k)) { + self.waiting.push(Waiting { node, k, rect, root, due: now + delay }); + } + false + } + + /// Run the delayed mouse triggers that are due (those whose screen or overlay went away are + /// dropped). True when one changed what shows. + fn fire_waiting(&mut self, root: NodeId, now: f64) -> bool { + let (due, later): (Vec, Vec) = std::mem::take(&mut self.waiting).into_iter().partition(|w| w.due <= now); + self.waiting = later; + self.waiting.retain(|w| w.root == root); + let mut did = false; + for w in due.into_iter().filter(|w| w.root == root) { + // An earlier one may have left the screen: the rest are dropped with it. + did = did || self.fire(w.node, w.k, w.rect, now); + } + did + } + /// Run the actions of interaction `k` on `node`. `rect` is the layer's screen rect. fn fire(&mut self, node: NodeId, k: usize, rect: Option, now: f64) -> bool { let Some(it) = self.doc.as_ref().and_then(|d| d.node(node)).and_then(|n| n.interactions.get(k)).cloned() else { return false }; @@ -753,7 +799,7 @@ impl Presenter { } true } - Action::OpenOverlay { dest, position, close_on_outside, background } => { + Action::OpenOverlay { dest, position, close_on_outside, background, offset } => { if !doc.contains(*dest) { return false; } @@ -764,6 +810,7 @@ impl Presenter { close_on_outside: *close_on_outside, background: *background, anchor: rect, + offset: *offset, opened: now, transition: tr, dur, @@ -791,6 +838,7 @@ impl Presenter { close_on_outside: true, background: None, anchor: rect, + offset: None, opened: now, transition: tr, dur, @@ -1351,8 +1399,10 @@ pub fn transition_frames(tr: Transition, t: f32) -> FramePlacement { } } -/// Screen rect of an overlay of `size` over the frame drawn at `base`. -pub fn overlay_rect(pos: OverlayPosition, base: Rect, size: Vec2, anchor: Option) -> Rect { +/// Screen rect of an overlay of `size` over the frame drawn at `base`. A manual one sits `offset` +/// (screen points) from the top-left of `anchor`, the layer that opened it; without an offset, +/// right below it. +pub fn overlay_rect(pos: OverlayPosition, base: Rect, size: Vec2, anchor: Option, offset: Option) -> Rect { let cx = base.center().x - size.x / 2.0; let cy = base.center().y - size.y / 2.0; let (x, y) = match pos { @@ -1363,7 +1413,11 @@ pub fn overlay_rect(pos: OverlayPosition, base: Rect, size: Vec2, anchor: Option OverlayPosition::BottomLeft => (base.min.x, base.max.y - size.y), OverlayPosition::BottomCenter => (cx, base.max.y - size.y), OverlayPosition::BottomRight => (base.max.x - size.x, base.max.y - size.y), - OverlayPosition::Manual => anchor.map(|a| (a.min.x, a.max.y)).unwrap_or((cx, cy)), + OverlayPosition::Manual => match (anchor, offset) { + (Some(a), Some(o)) => (a.min.x + o.x, a.min.y + o.y), + (Some(a), None) => (a.min.x, a.max.y), + (None, _) => (cx, cy), + }, }; Rect::from_min_size(pos2(x, y), size) } @@ -1527,7 +1581,8 @@ fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option { let place_overlay = |o: &OverlayState| { let ob = disp.world_bounds(o.id)?; let size = vec2((ob.width() * scale) as f32, (ob.height() * scale) as f32); - let r = overlay_rect(o.position, screen, size, o.anchor); + let offset = o.offset.map(|[x, y]| vec2((x * scale) as f32, (y * scale) as f32)); + let r = overlay_rect(o.position, screen, size, o.anchor, offset); Some((o.id, Place { page_min: (ob.x0, ob.y0), origin: r.min, scale }, r)) }; let overlays = p.overlays.iter().filter_map(place_overlay).collect(); @@ -1964,6 +2019,10 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) { crate::repaint_in(&ctx, due.min(60.0)); } + // And for the soonest mouse trigger waiting out its delay. + if let Some(due) = p.waiting.iter().map(|w| w.due - now).reduce(f64::min) { + crate::repaint_in(&ctx, due.clamp(0.0, 60.0)); + } action } @@ -2222,6 +2281,9 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke let doc = &l.disp; let hot = hotspots(doc, root, &place, clip); p.hot.clone_from(&hot); + if p.fire_waiting(root, now) { + return; + } // "While hovering / pressing" reverts. if let Some(tmp) = &p.temp { let leave = if tmp.press { released || !down } else { pos.is_none_or(|q| !tmp.rect.contains(q)) }; @@ -2280,14 +2342,14 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke p.hovered = ids; for id in left { if let Some(k) = find_trigger(doc, id, |t| *t == Trigger::MouseLeave) - && p.fire(id, k, None, now) + && p.gesture(id, k, None, now, root) { return; } } for (id, r) in &entered { if let Some(k) = find_trigger(doc, *id, |t| *t == Trigger::MouseEnter) - && p.fire(*id, k, Some(*r), now) + && p.gesture(*id, k, Some(*r), now, root) { return; } @@ -2319,7 +2381,7 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke return; } if let Some((id, k, r)) = pick(&|t| *t == Trigger::MouseDown) - && p.fire(id, k, Some(r), now) + && p.gesture(id, k, Some(r), now, root) { return; } @@ -2389,7 +2451,7 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke p.dragged = false; p.drag_scroll = None; if let Some((id, k, r)) = pick(&|t| *t == Trigger::MouseUp) - && p.fire(id, k, Some(r), now) + && p.gesture(id, k, Some(r), now, root) { return; } @@ -2614,6 +2676,10 @@ mod behaviour_tests; #[path = "present_tail_tests.rs"] mod tail_tests; +#[cfg(test)] +#[path = "present_delay_tests.rs"] +mod delay_tests; + #[cfg(test)] mod tests { use super::*; @@ -2637,6 +2703,7 @@ mod tests { close_on_outside: true, background: None, anchor: None, + offset: None, opened, transition, dur: 0.3, @@ -2906,11 +2973,11 @@ mod tests { fn overlay_positions() { let base = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 800.0)); let size = vec2(200.0, 100.0); - assert_eq!(overlay_rect(OverlayPosition::Center, base, size, None).min, pos2(100.0, 350.0)); - assert_eq!(overlay_rect(OverlayPosition::BottomCenter, base, size, None).min, pos2(100.0, 700.0)); - assert_eq!(overlay_rect(OverlayPosition::TopRight, base, size, None).min, pos2(200.0, 0.0)); + assert_eq!(overlay_rect(OverlayPosition::Center, base, size, None, None).min, pos2(100.0, 350.0)); + assert_eq!(overlay_rect(OverlayPosition::BottomCenter, base, size, None, None).min, pos2(100.0, 700.0)); + assert_eq!(overlay_rect(OverlayPosition::TopRight, base, size, None, None).min, pos2(200.0, 0.0)); let anchor = Rect::from_min_size(pos2(30.0, 40.0), vec2(50.0, 20.0)); - assert_eq!(overlay_rect(OverlayPosition::Manual, base, size, Some(anchor)).min, pos2(30.0, 60.0)); + assert_eq!(overlay_rect(OverlayPosition::Manual, base, size, Some(anchor), None).min, pos2(30.0, 60.0)); } /// Smart animate renders a temporary document per animation frame; keep that cheap. diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs index 8303293..791e0d3 100644 --- a/crates/ui-egui/src/present_behaviour_tests.rs +++ b/crates/ui-egui/src/present_behaviour_tests.rs @@ -218,7 +218,7 @@ fn act(p: &mut Presenter, a: Action, now: f64) -> bool { } fn open(dest: NodeId, close_on_outside: bool) -> Action { - Action::OpenOverlay { dest, position: OverlayPosition::Center, close_on_outside, background: None } + Action::OpenOverlay { dest, position: OverlayPosition::Center, close_on_outside, background: None, offset: None } } /// Screens A and B and three overlay frames M1, M2, M3 (all top-level). diff --git a/crates/ui-egui/src/present_delay_tests.rs b/crates/ui-egui/src/present_delay_tests.rs new file mode 100644 index 0000000..552a1df --- /dev/null +++ b/crates/ui-egui/src/present_delay_tests.rs @@ -0,0 +1,162 @@ +//! Delays on mouse enter, leave, down and up triggers, and manual overlay positions, in the +//! player: gestures go through egui headless on the harness's clock (a frame is 1/60 s). + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_doc::proto::OverlayPosition; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use super::overlay_rect; +use crate::present_tests::{Ui as Harness, id}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn frame(s: &mut Session, name: &str, x: f64, w: f64, h: f64) -> NodeId { + id(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": w, "height": h, "name": name}))) +} + +fn rect(s: &mut Session, parent: NodeId, [x, y, w, h]: [f64; 4]) -> NodeId { + id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h, "parent": parent}))) +} + +fn presenter(u: &Harness) -> Value { + u.app.inspect()["presenter"].clone() +} + +fn screen(u: &Harness) -> String { + presenter(u)["name"].as_str().unwrap_or_default().to_string() +} + +fn hotspot(u: &Harness, layer: NodeId) -> Rect { + let hot = presenter(u)["hotspots"].as_array().cloned().unwrap_or_default(); + let h = hot.iter().find(|h| h["id"] == json!(layer)).unwrap_or_else(|| panic!("no hotspot on {layer}: {hot:?}")); + let r: Vec = h["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + Rect::from_min_max(pos2(r[0], r[1]), pos2(r[2], r[3])) +} + +fn button(at: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +/// A (presented) with a 100 × 60 "Hot" layer that goes to B on `trigger` after `delay` ms. +fn delayed(trigger: &str, delay: u32) -> (Harness, Pos2) { + let mut s = Session::new(); + let a = frame(&mut s, "A", 0.0, 300.0, 400.0); + let b = frame(&mut s, "B", 400.0, 300.0, 400.0); + let hot = rect(&mut s, a, [50.0, 50.0, 100.0, 60.0]); + let it = json!({"id": hot, "trigger": {"type": trigger}, "action": {"type": "navigate", "dest": b}, "transition": {"type": "instant"}, "delayMs": delay}); + ex(&mut s, "proto.interaction.add", it); + let mut u = Harness::new(s); + u.run("proto.present", json!({"start": a})); + u.frames(2, Vec::new); + let at = hotspot(&u, hot).center(); + (u, at) +} + +/// The gesture that sets `trigger` off on the layer at `at`. +fn gesture(u: &mut Harness, trigger: &str, at: Pos2) { + let away = at - vec2(80.0, 45.0); + u.frame(vec![Event::PointerMoved(at)]); + match trigger { + "mouseEnter" => {} + "mouseLeave" => u.frame(vec![Event::PointerMoved(away)]), + "mouseDown" => u.frame(vec![button(at, true)]), + _ => { + u.frame(vec![button(at, true)]); + u.frame(vec![button(at, false)]); + } + } +} + +#[test] +fn mouse_triggers_wait_out_their_delay() { + for trigger in ["mouseEnter", "mouseLeave", "mouseDown", "mouseUp"] { + // 500 ms is 30 frames: not yet after 20 more, done after 40. + let (mut u, at) = delayed(trigger, 500); + gesture(&mut u, trigger, at); + u.frames(20, Vec::new); + assert_eq!(screen(&u), "A", "{trigger}: not before the delay"); + u.frames(20, Vec::new); + assert_eq!(screen(&u), "B", "{trigger}: once the delay is over"); + // Without a delay it acts at once. + let (mut u, at) = delayed(trigger, 0); + gesture(&mut u, trigger, at); + u.frames(2, Vec::new); + assert_eq!(screen(&u), "B", "{trigger}: no delay"); + } +} + +#[test] +fn a_delayed_trigger_fires_once_and_not_on_another_screen() { + // Entering twice while it waits doesn't queue it twice (the documentation is silent on a + // pointer that leaves early: it still fires). + let (mut u, at) = delayed("mouseEnter", 500); + for _ in 0..3 { + u.frame(vec![Event::PointerMoved(at)]); + u.frame(vec![Event::PointerMoved(at - vec2(80.0, 45.0))]); + } + assert_eq!(presenter(&u)["waiting"], 1); + u.frames(40, Vec::new); + assert_eq!((screen(&u), presenter(&u)["waiting"].clone()), ("B".to_string(), json!(0))); + // Restarting (or leaving the screen any other way) drops what was waiting. + let (mut u, at) = delayed("mouseDown", 500); + gesture(&mut u, "mouseDown", at); + u.frame(vec![button(at, false)]); + u.frame(vec![Event::Key { key: egui::Key::R, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + u.frames(40, Vec::new); + assert_eq!((screen(&u), presenter(&u)["waiting"].clone()), ("A".to_string(), json!(0)), "restarted while waiting"); +} + +#[test] +fn a_manual_overlay_sits_at_its_offset_from_the_layer_that_opens_it() { + let (base, size) = (Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 800.0)), vec2(200.0, 100.0)); + let anchor = Rect::from_min_size(pos2(30.0, 40.0), vec2(50.0, 20.0)); + let at = |offset| overlay_rect(OverlayPosition::Manual, base, size, Some(anchor), offset).min; + // (offset, top-left, derivation) + for (offset, want, why) in [ + (None, pos2(30.0, 60.0), "no offset: right below the layer"), + (Some(vec2(0.0, 0.0)), pos2(30.0, 40.0), "0, 0: on the layer's top-left"), + (Some(vec2(12.0, -8.0)), pos2(42.0, 32.0), "30 + 12, 40 - 8"), + ] { + assert_eq!(at(offset), want, "{why}"); + } + assert_eq!( + overlay_rect(OverlayPosition::Manual, base, size, None, Some(vec2(5.0, 5.0))).min, + pos2(100.0, 350.0), + "opened by nothing on screen: centred" + ); + assert_eq!( + overlay_rect(OverlayPosition::TopLeft, base, size, Some(anchor), Some(vec2(5.0, 5.0))).min, + pos2(0.0, 0.0), + "other positions ignore it" + ); +} + +#[test] +fn the_player_places_a_manual_overlay_in_page_units() { + // "Hot" at (50, 50) in A opens "Menu" at offset (20, 30); a layer at (10, 10) in Menu shows + // where the overlay landed: Hot's top-left + (20 + 10, 30 + 10) page units. + let mut s = Session::new(); + let a = frame(&mut s, "A", 0.0, 300.0, 400.0); + let menu = frame(&mut s, "Menu", 400.0, 120.0, 80.0); + let hot = rect(&mut s, a, [50.0, 50.0, 100.0, 60.0]); + let item = rect(&mut s, menu, [10.0, 10.0, 40.0, 20.0]); + let open = json!({"type": "openOverlay", "dest": menu, "position": "manual", "offset": [20, 30]}); + ex(&mut s, "proto.interaction.add", json!({"id": hot, "action": open, "transition": {"type": "instant"}})); + ex(&mut s, "proto.interaction.add", json!({"id": item, "action": {"type": "closeOverlay"}, "transition": {"type": "instant"}})); + let mut u = Harness::new(s); + u.run("proto.present", json!({"start": a})); + u.frames(2, Vec::new); + let h = hotspot(&u, hot); + let scale = h.width() / 100.0; + u.frame(vec![Event::PointerMoved(h.center())]); + u.frame(vec![button(h.center(), true)]); + u.frame(vec![button(h.center(), false)]); + u.frames(2, Vec::new); + let got = hotspot(&u, item).min; + let want = h.min + vec2(30.0, 40.0) * scale; + assert!((got - want).length() < 0.01, "{got:?} vs {want:?} at scale {scale}"); +} diff --git a/crates/ui-egui/src/present_variables_tests.rs b/crates/ui-egui/src/present_variables_tests.rs index 732cc7c..2349180 100644 --- a/crates/ui-egui/src/present_variables_tests.rs +++ b/crates/ui-egui/src/present_variables_tests.rs @@ -27,7 +27,8 @@ fn setup() -> (Document, NodeId, NodeId, NodeId) { } fn on_click(doc: &mut Document, node: NodeId, actions: Vec) { - let it = Interaction { trigger: Trigger::OnClick, actions, transition: Default::default(), duration_ms: 0, easing: Default::default() }; + let it = + Interaction { trigger: Trigger::OnClick, actions, transition: Default::default(), duration_ms: 0, easing: Default::default(), delay_ms: 0 }; doc.get_mut(node).unwrap().interactions.push(it); } diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 263912a..79ae3eb 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -219,7 +219,9 @@ pub(crate) fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId, Action::ScrollTo { dest, offset: [0.0, 0.0] } } "Open link" => Action::OpenUrl { url: String::new() }, - "Open overlay" => Action::OpenOverlay { dest: frame_dest?, position: OverlayPosition::Center, close_on_outside: true, background: None }, + "Open overlay" => { + Action::OpenOverlay { dest: frame_dest?, position: OverlayPosition::Center, close_on_outside: true, background: None, offset: None } + } "Swap overlay" => Action::SwapOverlay { dest: frame_dest? }, "Close overlay" => Action::CloseOverlay, "Set variable" => { @@ -694,6 +696,18 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte }); }); match &it.trigger { + Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => { + row(ui, 32.0, |ui| { + widgets::label(ui, tr("Delay"), t.text_secondary); + let id = Id::new(("proto_mouse_delay", node, k)); + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = + widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0) + && v.is_finite() + { + patch(app, node, k, json!({"delayMs": v.clamp(0.0, 600_000.0).round()})); + } + }); + } Trigger::AfterDelay { ms } => { row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); @@ -861,9 +875,9 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } }); } - Action::OpenOverlay { dest, position, close_on_outside, background } => { - let (dest, position, close_on_outside, background) = (*dest, *position, *close_on_outside, *background); - let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background }; + Action::OpenOverlay { dest, position, close_on_outside, background, offset } => { + let (dest, position, close_on_outside, background, offset) = (*dest, *position, *close_on_outside, *background, *offset); + let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background, offset }; dest_dropdown(app, ui, doc, &frames, Some(dest), tr("Overlay"), mk, slot); widgets::sublabels(ui, tr("Overlay position"), None); row(ui, 32.0, |ui| { @@ -871,21 +885,42 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: menu_popup(&resp).show(|ui| { for p in OverlayPosition::ALL { if menu_item(ui, tr(p.label()), "") { - set_action(app, slot, Action::OpenOverlay { dest, position: p, close_on_outside, background }); + set_action(app, slot, Action::OpenOverlay { dest, position: p, close_on_outside, background, offset }); ui.close(); } } }); }); + if position == OverlayPosition::Manual { + // From the top-left of the layer that opens it; empty: right below that layer. + row(ui, 32.0, |ui| { + let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); + for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() { + let shown = offset.and_then(|o| o.get(i).copied()); + let Some(e) = widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, tr("Auto")).0 + else { + continue; + }; + let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = e.settled() else { continue }; + if v.is_finite() { + let mut o = offset.unwrap_or([0.0, 0.0]); + if let Some(slot_v) = o.get_mut(i) { + *slot_v = v.clamp(-100_000.0, 100_000.0).round(); + } + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background, offset: Some(o) }); + } + } + }); + } row(ui, 28.0, |ui| { if widgets::checkbox(ui, close_on_outside, tr("Close when clicking outside")) { - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside: !close_on_outside, background }); + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside: !close_on_outside, background, offset }); } }); row(ui, 28.0, |ui| { if widgets::checkbox(ui, background.is_some(), tr("Add background behind overlay")) { let bg = if background.is_some() { None } else { Some(figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25)) }; - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: bg }); + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: bg, offset }); } }); if let Some(bg) = background { @@ -894,7 +929,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: if let Some(h) = widgets::text_field(ui, slot.id("proto_ovbg"), &bg.to_hex(), 96.0, "") && let Some(c) = figcraft_color::Color::from_hex(&h) { - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c) }); + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c), offset }); } let alpha = (bg.a * 100.0).round(); if let Some(e) = widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) { @@ -903,7 +938,11 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: }; if v.is_finite() { let a = v.clamp(0.0, 100.0) as f32 / 100.0; - set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)) }); + set_action( + app, + slot, + Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset }, + ); } } }); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15a8be5..15c096d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -81,7 +81,7 @@ an idle window draws none) with the most frequent repaint requests behind them ( the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its -`name`, `start`, `history`, open `overlays`, the screen's `scroll` offset and `scrollRange`, the +`name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the `device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame its screen shows, the presented frame's `frameSize` and, for a responsive prototype, the screen it was resized to (`responsive`: `[w, h]`, else `null`), the size option (`scale`: diff --git a/docs/mcp.md b/docs/mcp.md index de8ead0..a3ff4ce 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -417,7 +417,9 @@ right, left) and `oneSided`. Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep the scroll position with `preserveScroll: true`; `{type: "scrollTo", dest, offset?: [x, y]}` stops the layer that far from the top-left of the innermost frame scrolling it, e.g. below a fixed -header). A key trigger takes a key or a combination (`{type: "keyPress", key: "Shift+K"}`; Ctrl, +header; `delayMs` makes a mouse enter, leave, down or up trigger wait before it acts; +`{type: "openOverlay", dest, position: "manual", offset?: [x, y]}` puts the overlay that far from +the top-left of the layer that opens it, and right below that layer without an offset). A key trigger takes a key or a combination (`{type: "keyPress", key: "Shift+K"}`; Ctrl, Alt, Shift and Cmd, all held and no others) or a controller button (`key: "Gamepad A"`; `Gamepad` followed by `A`, `B`, `X`, `Y`, `LB`, `RB`, `LT`, `RT`, `View`, `Menu`, `LS`, `RS`, `Up`, `Down`, `Left`, `Right` or `Home`: the standard layout, face buttons by position), which the player reads diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 3bf4afa..b922048 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -49,7 +49,8 @@ case pins the current behaviour). | Scroll to with an offset: the layer stops that far from the top-left (room for a fixed header) | **added** | `offset: [x, y]` on the action, X / Y fields in the interaction editor; `scroll_to_brings_the_layer_to_the_top_less_its_offset` (nested scrolling frames: the innermost keeps the offset; silent) | | Open overlay: positions, background dimming, close on a click outside (on by default, can be turned off) | pass | `a_click_outside_an_overlay_closes_it_unless_turned_off`, `overlay_positions` | | Overlays stack; swap overlay replaces the top one; close overlay closes it | pass | `overlays_stack_swap_close_and_go_with_navigation` | -| Manual overlay position relative to the hotspot | silent | below the layer that opened it; no offset is stored yet | +| Manual overlay position relative to the hotspot | **added** | `offset: [x, y]` on the action, from the top-left of the layer that opens it (X / Y fields in the interaction editor; without one, below that layer); `present_delay_tests` | +| Delay on mouse enter / leave / down / up | **added** | `delayMs` on the interaction, a Delay field in the editor; fires once after the delay while its screen or overlay still shows (a pointer that leaves early: silent, it still fires); `mouse_triggers_wait_out_their_delay` | | Open link | pass | always a new tab or browser window (opening in place would leave the prototype) | | Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` | | Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` | @@ -89,7 +90,7 @@ case pins the current behaviour). | Hotspot hints, inspect panel in the presenter, comments in the presenter | done | | | Preview pane following edits live | done | | | Edit interactions on several layers at once | done | | -| Delays on mouse enter / leave / down / up; manual overlay offsets; opening a link in place | missing | the REST import warns about mouse-trigger delays | +| Opening a link in place | missing | | | Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet | | Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) | | Video in prototypes | missing | needs video fills ([gaps](gaps.md) #12, #15) | @@ -99,6 +100,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | | 2026-10-11 | minor | Behaviour suite from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag follows its animation's direction and the pointer, key combinations, overlay timers from when they open, interactive components play their variant's interactions and change the right instance, physical springs that play until they settle, smart animate overshoots with the easing, fixed layers above scrolling content; added scroll to offsets. Breadth 88% → 90%, ready 70% → 74%, 5–9 h → 3–6 h | | 2026-10-10 | major | Created from the 2026-10-10 re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bccdd32..3bd6e8f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -106,7 +106,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Variables inside styles, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | -| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | +| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | @@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | | 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials: discoverability ×0.92 → ×0.94 (templates and first-run tips exist; no Community files, no tour, no user evidence), 77.8% × 0.92 × 0.94 × 0.88 = 59.2%, ~57% → ~59%; essentials hours ~60–110 h → ~55–100 h (the onboarding and templates item is done); menu breadth stays 211/211 (the two new menu items are FigCraft's own and not counted); every other number unchanged | | 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |