M5: prototyping behaviour suite and fixes

**A prototyping behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule on triggers, actions, overlays, animations, easing, scrolling and interactive components is a case with what the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. The model rules are table-driven tests in the engine; the player's are driven through egui headless (real pointer, key and timer input into the presenter) or on the player directly.

**Fixes (the suite failed on these before them)**
- **On drag follows its animation**: with a move, push or slide animation, only a drag that way starts it (a drag another way can scroll instead), and the animation follows the pointer while the button stays down, then plays on from there when it lets go (it played by time, and any drag direction fired it).
- **Key combinations**: a key trigger can be a combination (`Shift+K`, `Ctrl+Alt+Delete`, other spellings of the modifiers accepted); the modifiers held must match, so `K` no longer fires for Shift+K. The interaction editor's recorder records the modifiers held, and REST files' modifier key codes import as the combination (they imported as the key alone, with a warning).
- **Overlay timers**: an overlay's after-delay timer starts when the overlay opens, and again each time it opens (it was timed from when the screen under it arrived, so a toast opened late closed at once, and it fired only the first time).
- **Interactive components**: every instance plays the interactions on the variant it shows; an interaction on the instance with the same trigger wins. Before, instances played only interactions copied onto them when 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 (`proto_run::play_interactions`, on the player's copy).
- **Change to on nested instances** changes the instance whose variant set holds the destination (`proto_run::change_to_target`), not the innermost instance (an icon inside a button).
- **Springs**: the presets are physical springs (mass 1; stiffness / damping gentle 100/15, quick 300/20, bouncy 600/15, slow 80/20; they were ad-hoc curves), and every spring plays until it settles instead of being squeezed into the duration field's time; the settling time accounts for over-damped springs. The interaction editor shows the spring's duration in place of the duration field, with a tooltip.
- **Smart animate overshoot**: matched layers follow spring and back easings past their end and back (they snapped to the end once the curve passed 1).
- **Fixed layers on top**: fixed layers draw and take clicks above the content scrolling under them, wherever they are in the layer list (the file's order doesn't change).

**Missing documented setting, added**
- **Scroll to offset**: `{type: "scrollTo", dest, offset: [x, y]}` stops the layer that far from the top-left of the innermost frame scrolling it (room for a fixed header); X / Y fields in the interaction editor; files carry it only when set.
- Already there and now covered by the suite: click / mouse down / mouse up / while pressing / while hovering / mouse enter / mouse leave / after delay, nested hotspot priority and click-through, navigate (closing overlays), back with the reversed animation, restart, overlay stacking / swap / close / close on a click outside, change to with smart animate, reversed directional transitions, easing curves.

New strings go through `tr(...)` with `en.toml` / `es.toml` entries. No new command ids. The player still draws nothing while idle: the drag-follow only runs during an animation, and fixed layers and instance interactions are worked out once per layout, not per frame.
This commit is contained in:
bflatastic 2026-10-12 00:24:12 +07:00 committed by BFlat
parent 62db8d4e36
commit b65cc2e4fe
19 changed files with 1221 additions and 141 deletions

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
@ -17,16 +17,16 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Feature breadth | **~80%** (core editor ~91%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~49%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~47%** | estimated: weekly areas 68.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Mainstream practitioner | **~48%** | estimated: weekly areas 69.1% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~173–310 h** one agent | estimated |
| Remaining to full parity | **~315–607 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
| Remaining to full parity | **~313–604 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~49% | ~300–576 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
| Mainstream practitioner | ~47% | ~135–251 h | core editor depth incl. the `.fig` import's tail (53–104), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Full target (ready for real work) | ~49% | ~298–573 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
| Mainstream practitioner | ~48% | ~133–248 h | core editor depth incl. the `.fig` import's tail (51–101), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h).
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 57% (core editor 69%) | 54–104 | [target-app-parity](docs/target-app-parity.md) |
| Features (depth) | 57% (core editor 69%) | 52–101 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
@ -84,7 +84,7 @@ Figma's own languages on the landed localization layer (15–25 h).
| Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) |
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
| Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) |
| Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
| Prototyping | 90% | 74% | 3–6 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 90% | 64% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) |
| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding |
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |

View File

@ -1014,7 +1014,7 @@ impl Document {
fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
use proto::Action::*;
match a {
Navigate { dest, .. } | ScrollTo { dest } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => {
Navigate { dest, .. } | ScrollTo { dest, .. } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => {
if let Some(m) = map.get(dest) {
*dest = *m;
}

View File

@ -50,6 +50,73 @@ impl Trigger {
Trigger::KeyPress { key } => format!("Key/Gamepad {key}"),
}
}
/// A key trigger fires for the key named `key` (`Space`, `K`, `ArrowLeft`) pressed with
/// exactly the modifiers `mods`: its key is a combination such as `Shift+K`.
pub fn fires_on_key(&self, key: &str, mods: KeyMods) -> bool {
let Trigger::KeyPress { key: want } = self else { return false };
let (m, k) = KeyMods::split(want);
m == mods && (k.eq_ignore_ascii_case(key) || key_alias(k) == Some(key))
}
}
/// The modifier keys of a key combination.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct KeyMods {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
/// Command (macOS), the Windows key elsewhere.
pub cmd: bool,
}
impl KeyMods {
/// `key` with these modifiers, as a key trigger stores it: `Ctrl+Alt+Shift+Cmd+K`.
pub fn combo(self, key: &str) -> String {
let mut s = String::new();
for (on, name) in [(self.ctrl, "Ctrl"), (self.alt, "Alt"), (self.shift, "Shift"), (self.cmd, "Cmd")] {
if on {
s.push_str(name);
s.push('+');
}
}
s + key
}
/// A key combination's modifiers and key (`Shift+K`, `Ctrl++`, `K`; other spellings of the
/// modifiers too: Control, Option, Command, ⌘…).
pub fn split(combo: &str) -> (KeyMods, &str) {
let mut mods = KeyMods::default();
let mut rest = combo.trim();
while let Some((m, k)) = rest.split_once('+').filter(|(_, k)| !k.is_empty()) {
match m.trim().to_ascii_lowercase().as_str() {
"ctrl" | "control" | "⌃" => mods.ctrl = true,
"alt" | "option" | "opt" | "⌥" => mods.alt = true,
"shift" | "⇧" => mods.shift = true,
"cmd" | "command" | "meta" | "super" | "win" | "⌘" => mods.cmd = true,
_ => break,
}
rest = k.trim();
}
(mods, rest)
}
}
/// Alternative spellings people type for keys, as the keys' names.
pub fn key_alias(k: &str) -> Option<&'static str> {
Some(match k.to_ascii_lowercase().as_str() {
"space" | " " => "Space",
"return" | "enter" => "Enter",
"esc" | "escape" => "Escape",
"left" | "arrowleft" | "←" => "ArrowLeft",
"right" | "arrowright" | "→" => "ArrowRight",
"up" | "arrowup" | "↑" => "ArrowUp",
"down" | "arrowdown" | "↓" => "ArrowDown",
"tab" => "Tab",
"backspace" => "Backspace",
"delete" => "Delete",
_ => return None,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@ -131,9 +198,30 @@ impl Easing {
}
}
/// Spring presets run longer than the chosen duration in the incumbent; we keep the duration.
/// A spring's mass, stiffness and damping (None for the curves). The presets are physical
/// springs too.
pub fn spring(self) -> Option<(f64, f64, f64)> {
match self {
Easing::Gentle => Some((1.0, 100.0, 15.0)),
Easing::Quick => Some((1.0, 300.0, 20.0)),
Easing::Bouncy => Some((1.0, 600.0, 15.0)),
Easing::Slow => Some((1.0, 80.0, 20.0)),
Easing::CustomSpring { mass, stiffness, damping } => Some((mass, stiffness, damping)),
_ => None,
}
}
pub fn is_spring(self) -> bool {
matches!(self, Easing::Gentle | Easing::Quick | Easing::Bouncy | Easing::Slow | Easing::CustomSpring { .. })
self.spring().is_some()
}
/// How long an animation with this easing plays (seconds) when its duration is set to `ms`:
/// a spring runs until it settles, whatever the duration says.
pub fn seconds(self, ms: u32) -> f64 {
match self.spring() {
Some((m, k, c)) => Spring::new(m, k, c).settle,
None => f64::from(ms) / 1000.0,
}
}
/// Evaluate the curve at `t` (0..1).
@ -161,13 +249,11 @@ impl Easing {
((2.0 * t - 2.0).powi(2) * ((c2 + 1.0) * (t * 2.0 - 2.0) + c2) + 2.0) / 2.0
}
}
// Spring presets approximated by damped oscillations.
Easing::Gentle => spring(t, 6.0, 0.7),
Easing::Quick => spring(t, 10.0, 0.8),
Easing::Bouncy => spring(t, 9.0, 0.35),
Easing::Slow => spring(t, 4.0, 0.9),
Easing::Gentle | Easing::Quick | Easing::Bouncy | Easing::Slow | Easing::CustomSpring { .. } => {
let (m, k, c) = self.spring().unwrap_or((1.0, 100.0, 15.0));
Spring::new(m, k, c).at(t)
}
Easing::CustomBezier { x1, y1, x2, y2 } => cubic_bezier(t, x1, y1, x2, y2),
Easing::CustomSpring { mass, stiffness, damping } => physical_spring(t, mass, stiffness, damping),
}
}
}
@ -194,39 +280,48 @@ fn cubic_bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
b(y1, y2, (lo + hi) / 2.0)
}
/// A unit step response of a damped spring, with its settling time mapped onto 0..1.
fn physical_spring(t: f64, mass: f64, stiffness: f64, damping: f64) -> f64 {
if t >= 1.0 {
return 1.0;
}
let ok = |v: f64, lo: f64, d: f64| if v.is_finite() && v >= lo { v } else { d };
let m = ok(mass, 0.01, 1.0).min(1e4);
let k = ok(stiffness, 0.01, 100.0).min(1e6);
let c = ok(damping, 0.0, 15.0).min(1e6);
let w0 = (k / m).sqrt();
let zeta = c / (2.0 * (k * m).sqrt());
// Time to settle within 0.1% (an undamped spring is cut at 10 s).
let settle = if zeta > 1e-6 { (6.9 / (zeta.min(1.0) * w0)).clamp(0.05, 10.0) } else { 10.0 };
let tau = t * settle;
let x = if zeta < 1.0 {
let wd = w0 * (1.0 - zeta * zeta).sqrt();
1.0 - (-zeta * w0 * tau).exp() * ((wd * tau).cos() + zeta * w0 / wd * (wd * tau).sin())
} else if (zeta - 1.0).abs() < 1e-9 {
1.0 - (-w0 * tau).exp() * (1.0 + w0 * tau)
} else {
let s = (zeta * zeta - 1.0).sqrt();
let (r1, r2) = (-w0 * (zeta - s), -w0 * (zeta + s));
1.0 - (r2 * (r1 * tau).exp() - r1 * (r2 * tau).exp()) / (r2 - r1)
};
if x.is_finite() { x } else { 1.0 }
/// A damped spring (unit mass-spring-damper released from 0 towards 1).
struct Spring {
w0: f64,
zeta: f64,
/// Seconds until it stays within ~0.1% of the end (an undamped spring is cut at 10 s).
settle: f64,
}
fn spring(t: f64, freq: f64, damping: f64) -> f64 {
if t >= 1.0 {
return 1.0;
impl Spring {
fn new(mass: f64, stiffness: f64, damping: f64) -> Self {
let ok = |v: f64, lo: f64, d: f64| if v.is_finite() && v >= lo { v } else { d };
let m = ok(mass, 0.01, 1.0).min(1e4);
let k = ok(stiffness, 0.01, 100.0).min(1e6);
let c = ok(damping, 0.0, 15.0).min(1e6);
let w0 = (k / m).sqrt();
let zeta = c / (2.0 * (k * m).sqrt());
// The slowest mode's decay rate (overdamped: w0 (zeta - sqrt(zeta² - 1)), written stably);
// e^-6.9 is 0.1%, and a critically or over-damped spring's larger lead factor needs ~9.2.
let (rate, k_settle) = if zeta < 1.0 { (zeta * w0, 6.9) } else { (w0 / (zeta + (zeta * zeta - 1.0).sqrt()), 9.2) };
let settle = if rate > 1e-6 { (k_settle / rate).clamp(0.05, 10.0) } else { 10.0 };
Spring { w0, zeta, settle: if settle.is_finite() { settle } else { 10.0 } }
}
/// Its position at `t` (0..1) of its settling time.
fn at(&self, t: f64) -> f64 {
if t >= 1.0 {
return 1.0;
}
let (w0, zeta) = (self.w0, self.zeta);
let tau = t * self.settle;
let x = if zeta < 1.0 {
let wd = w0 * (1.0 - zeta * zeta).sqrt();
1.0 - (-zeta * w0 * tau).exp() * ((wd * tau).cos() + zeta * w0 / wd * (wd * tau).sin())
} else if (zeta - 1.0).abs() < 1e-9 {
1.0 - (-w0 * tau).exp() * (1.0 + w0 * tau)
} else {
let s = (zeta * zeta - 1.0).sqrt();
let (r1, r2) = (-w0 / (zeta + s), -w0 * (zeta + s));
1.0 - (r2 * (r1 * tau).exp() - r1 * (r2 * tau).exp()) / (r2 - r1)
};
if x.is_finite() { x } else { 1.0 }
}
let decay = (-damping * 8.0 * t).exp();
1.0 - decay * (freq * t * std::f64::consts::PI).cos() * (1.0 - t)
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
@ -284,6 +379,19 @@ impl Transition {
}
}
/// Whether a drag of `(dx, dy)` (screen space, y down) starts it on an "on drag" trigger: a
/// directional transition only for a drag mostly its way (a push left for a drag to the left),
/// the others for a drag any way.
pub fn accepts_drag(&self, dx: f32, dy: f32) -> bool {
match self.direction() {
None => true,
Some(Direction::Left) => dx < 0.0 && dx.abs() >= dy.abs(),
Some(Direction::Right) => dx > 0.0 && dx.abs() >= dy.abs(),
Some(Direction::Top) => dy < 0.0 && dy.abs() >= dx.abs(),
Some(Direction::Bottom) => dy > 0.0 && dy.abs() >= dx.abs(),
}
}
/// The opposite motion (used when a "while hovering/pressing" state reverts).
pub fn reversed(self) -> Transition {
let opp = |d: Direction| match d {
@ -368,6 +476,10 @@ pub enum Action {
Back,
ScrollTo {
dest: NodeId,
/// Where the layer ends up from the scrolling frame's top-left (x, y): room left above it,
/// e.g. for a fixed header.
#[serde(default, skip_serializing_if = "is_origin")]
offset: [f64; 2],
},
OpenOverlay {
dest: NodeId,
@ -534,7 +646,7 @@ impl Action {
pub fn dest(&self) -> Option<NodeId> {
match self {
Action::Navigate { dest, .. }
| Action::ScrollTo { dest }
| Action::ScrollTo { dest, .. }
| Action::OpenOverlay { dest, .. }
| Action::SwapOverlay { dest }
| Action::ChangeTo { dest } => Some(*dest),
@ -564,6 +676,10 @@ fn is_false(b: &bool) -> bool {
!*b
}
fn is_origin(p: &[f64; 2]) -> bool {
*p == [0.0, 0.0]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Overflow {

View File

@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate {preserveScroll?}|back|scrollTo|openOverlay {position?, close_on_outside? (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)}}, 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}",
true,
add
),

View File

@ -280,6 +280,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.prop_refs.clear();
root.annotations.clear();
root.code_links.clear();
// The main's interactions play on its instances in prototypes; the instance's own start empty.
root.interactions.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)

View File

@ -1288,6 +1288,8 @@ mod plugins_tests;
#[cfg(test)]
mod progressive_blur_tests;
#[cfg(test)]
mod proto_behaviour_tests;
#[cfg(test)]
mod proto_conditional_tests;
#[cfg(test)]
mod proto_multi_tests;

View File

@ -0,0 +1,288 @@
//! Prototyping behaviour, written from the public documentation of prototype triggers, actions,
//! animations, easing and interactive components. Each case states the rule, what it implies and
//! a one-line derivation; where the documentation is silent the case says so and pins what
//! FigCraft does. The player's side (gestures, overlays, timers, scrolling, smart animate) is
//! `ui-egui`'s `present_behaviour_tests`.
use figcraft_doc::proto::{Action, Direction, Easing, KeyMods, Transition, Trigger};
use figcraft_doc::{Document, NodeId};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::proto_run::{change_to_target, hotspots, play_interactions};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
#[test]
fn on_drag_starts_only_a_drag_its_animation_goes() {
let push = |direction| Transition::Push { direction };
// (animation, drag dx, dy (y down), starts it, derivation)
let cases = [
(push(Direction::Left), -40.0, 5.0, true, "push left: the screen is dragged to the left"),
(push(Direction::Left), 40.0, 0.0, false, "push left: a drag to the right goes the other way"),
(push(Direction::Left), -10.0, 30.0, false, "mostly downwards is not its way (the drag can scroll instead)"),
(push(Direction::Right), 25.0, -3.0, true, "push right: a drag to the right"),
(Transition::MoveIn { direction: Direction::Top }, 0.0, -25.0, true, "move in upwards: a drag up"),
(Transition::MoveOut { direction: Direction::Top }, 0.0, 25.0, false, "move out upwards: not a drag down"),
(Transition::SlideOut { direction: Direction::Bottom }, 3.0, 25.0, true, "slide out downwards: a drag down"),
(Transition::SlideIn { direction: Direction::Right }, 20.0, -20.0, true, "exactly diagonal counts for both axes (docs silent; pinned)"),
(Transition::SmartAnimate, 0.0, 30.0, true, "smart animate has no direction: any drag"),
(Transition::Dissolve, -30.0, 0.0, true, "dissolve: any drag"),
(Transition::Instant, 7.0, 0.0, true, "instant: any drag"),
];
for (tr, dx, dy, want, why) in cases {
assert_eq!(tr.accepts_drag(dx, dy), want, "{tr:?} dragged ({dx}, {dy}): {why}");
}
}
#[test]
fn key_triggers_fire_for_their_key_or_combination() {
let none = KeyMods::default();
let shift = KeyMods { shift: true, ..none };
let ctrl_shift = KeyMods { ctrl: true, shift: true, ..none };
let alt_cmd = KeyMods { alt: true, cmd: true, ..none };
// (the trigger's key, key pressed, modifiers held, fires, derivation)
let cases = [
("K", "K", none, true, "the key alone"),
("k", "K", none, true, "letters in either case"),
("K", "K", shift, false, "a combination is a trigger of its own: K doesn't fire for Shift+K"),
("Shift+K", "K", shift, true, "the combination recorded"),
("Shift+K", "K", none, false, "not without its modifier"),
("Ctrl+Shift+K", "K", ctrl_shift, true, "two modifiers"),
("Ctrl+Shift+K", "K", shift, false, "every modifier of the combination must be held"),
("Command+Option+K", "K", alt_cmd, true, "other spellings of the modifiers (FigCraft accepts them)"),
("Space", "Space", none, true, "named keys"),
("space", "Space", none, true, "names in any case"),
("Return", "Enter", none, true, "common other names of keys"),
("←", "ArrowLeft", none, true, "arrow glyphs"),
("Shift+Space", "Space", shift, true, "a named key in a combination"),
("J", "K", none, false, "another key"),
];
for (want, key, mods, fires, why) in cases {
let t = Trigger::KeyPress { key: want.into() };
assert_eq!(t.fires_on_key(key, mods), fires, "{want:?} vs {mods:?}+{key}: {why}");
}
assert!(!Trigger::OnClick.fires_on_key("K", none), "only key triggers take keys");
// Combinations are stored in one order and read back exactly.
for bits in 0..16u8 {
let m = KeyMods { ctrl: bits & 1 != 0, alt: bits & 2 != 0, shift: bits & 4 != 0, cmd: bits & 8 != 0 };
assert_eq!(KeyMods::split(&m.combo("K")), (m, "K"));
}
assert_eq!(ctrl_shift.combo("K"), "Ctrl+Shift+K");
// A plus key keeps its plus; junk is a key that never comes.
assert_eq!(KeyMods::split("Ctrl++"), (KeyMods { ctrl: true, ..none }, "+"));
assert_eq!(KeyMods::split("+"), (none, "+"));
assert_eq!(KeyMods::split("Hyper+K"), (none, "Hyper+K"));
}
#[test]
fn easing_curves_and_springs() {
let customs = [Easing::CUSTOM_BEZIER, Easing::CUSTOM_SPRING];
for e in Easing::ALL.iter().chain(customs.iter()).copied() {
assert!(e.eval(0.0).abs() < 1e-9 && (e.eval(1.0) - 1.0).abs() < 1e-9, "{e:?}: every easing starts at 0 and ends at 1");
}
// (easing, t, eased below/above t, derivation)
assert_eq!(Easing::Linear.eval(0.3), 0.3, "linear: constant speed");
assert!(Easing::EaseIn.eval(0.25) < 0.25, "ease in starts slowly");
assert!(Easing::EaseOut.eval(0.25) > 0.25, "ease out starts fast and slows down");
assert!((Easing::EaseInOut.eval(0.5) - 0.5).abs() < 1e-9, "ease in and out: symmetric about the middle");
let curve = |e: Easing| (0..=100).map(move |i| e.eval(f64::from(i) / 100.0));
assert!(curve(Easing::EaseInBack).any(|v| v < -0.05), "ease in back pulls back before it goes");
assert!(curve(Easing::EaseOutBack).any(|v| v > 1.05), "ease out back overshoots, then settles");
// Springs are physical: mass 1 with stiffness / damping 100/15 (gentle), 300/20 (quick),
// 600/15 (bouncy), 80/20 (slow). Overshoot of an under-damped spring: e^(-πζ/√(1-ζ²)).
// (preset, ζ = c / 2√(km), overshoot expected, settle seconds)
let springs = [
(Easing::Gentle, 0.75, 0.028, 0.92),
(Easing::Quick, 0.577, 0.108, 0.69),
(Easing::Bouncy, 0.306, 0.36, 0.92),
(Easing::Slow, 1.118, 0.0, 1.66),
];
for (e, zeta, overshoot, secs) in springs {
let (m, k, c) = e.spring().unwrap();
assert!((c / (2.0 * (k * m).sqrt()) - zeta).abs() < 1e-3, "{e:?}: damping ratio {zeta}");
let peak = curve(e).fold(0.0, f64::max) - 1.0;
assert!((peak.max(0.0) - overshoot).abs() < 0.01, "{e:?}: overshoot {peak} ≈ {overshoot}");
// A spring plays until it settles, whatever duration is set.
assert!((e.seconds(300) - secs).abs() < 0.01, "{e:?}: {} s ≈ {secs} s", e.seconds(300));
assert_eq!(e.seconds(300), e.seconds(5000), "{e:?}: the duration field doesn't apply");
assert!((e.eval(0.98) - 1.0).abs() < 0.005, "{e:?}: at rest when it ends (nothing jumps)");
}
// Curves play for the duration set.
assert_eq!(Easing::EaseOut.seconds(300), 0.3);
assert_eq!(Easing::CUSTOM_BEZIER.seconds(1200), 1.2);
// Junk springs stay finite and bounded.
for e in [
Easing::CustomSpring { mass: 0.0, stiffness: -1.0, damping: f64::NAN },
Easing::CustomSpring { mass: 1e300, stiffness: 1e-300, damping: 0.0 },
Easing::CustomSpring { mass: 1.0, stiffness: 100.0, damping: 1e9 },
] {
let s = e.seconds(300);
assert!((0.05..=10.0).contains(&s), "{e:?}: {s}");
assert!(curve(e).all(f64::is_finite), "{e:?}");
}
}
#[test]
fn back_and_reverting_play_the_animation_the_other_way() {
// (animation going forward, played back, derivation)
let cases = [
(
Transition::MoveIn { direction: Direction::Left },
Transition::MoveOut { direction: Direction::Right },
"what moved in leaves the way it came",
),
(Transition::MoveOut { direction: Direction::Top }, Transition::MoveIn { direction: Direction::Bottom }, "what moved out comes back"),
(Transition::Push { direction: Direction::Left }, Transition::Push { direction: Direction::Right }, "a push left pushes back right"),
(Transition::SlideIn { direction: Direction::Top }, Transition::SlideOut { direction: Direction::Bottom }, "slide in up, slide out down"),
(Transition::SlideOut { direction: Direction::Left }, Transition::SlideIn { direction: Direction::Right }, "slide out left, slide in right"),
(Transition::Dissolve, Transition::Dissolve, "a dissolve has no direction"),
(Transition::SmartAnimate, Transition::SmartAnimate, "smart animate tweens back"),
(Transition::Instant, Transition::Instant, "instant stays instant"),
];
for (fwd, back, why) in cases {
assert_eq!(fwd.reversed(), back, "{fwd:?}: {why}");
assert_eq!(back.reversed(), fwd, "{back:?}: reversing twice is the original");
}
}
/// A button component set (`State=Default`, `State=Pressed`) whose variants each hold an icon
/// instance; Default's root goes to Pressed on click and on press, Pressed's back on click. A
/// screen holds an instance of the set. Returns the session, the screen, the button instance, the nested icon instance in it
/// and the Pressed variant.
fn interactive_button() -> (Session, NodeId, NodeId, NodeId, NodeId) {
let mut s = Session::new();
let icon_frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 10, "height": 10, "name": "Icon"})));
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon_frame]})));
let mut variants = Vec::new();
for (k, state) in ["Default", "Pressed"].iter().enumerate() {
let f =
idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")})));
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg"}}));
ex(&mut s, "instance.create", json!({"component": icon, "x": 4, "y": 4, "parent": f}));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
let (default, pressed) = (variants[0], variants[1]);
for trigger in ["onClick", "onPress"] {
ex(&mut s, "proto.interaction.add", json!({"id": default, "trigger": {"type": trigger}, "action": {"type": "changeTo", "dest": pressed}}));
}
ex(&mut s, "proto.interaction.add", json!({"id": pressed, "trigger": {"type": "onClick"}, "action": {"type": "changeTo", "dest": default}}));
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 300, "height": 300, "name": "Screen"})));
let button = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen})));
let nested = s
.doc()
.unwrap()
.subtree(button)
.into_iter()
.skip(1)
.find(|i| matches!(s.doc().unwrap().node(*i).map(|n| &n.kind), Some(figcraft_doc::NodeKind::Instance { .. })));
(s, screen, button, nested.unwrap(), pressed)
}
#[test]
fn instances_play_their_variants_interactions() {
let (mut s, screen, button, nested, pressed) = interactive_button();
let triggers = |d: &Document, id| d.node(id).unwrap().interactions.iter().map(|it| it.trigger.clone()).collect::<Vec<_>>();
// The file keeps the interactions on the main component; the player's copy plays them on
// every instance (interactive components).
let mut play = s.doc().unwrap().clone();
assert!(triggers(&play, button).is_empty());
play_interactions(&mut play);
assert_eq!(triggers(&play, button), [Trigger::OnClick, Trigger::OnPress], "an instance plays its variant's interactions");
assert_eq!(hotspots(&play, screen).first().map(|h| h.0), Some(button), "so it is a hotspot on the screen");
// An interaction on the instance with the same trigger wins over the main's.
ex(&mut s, "proto.interaction.add", json!({"id": button, "trigger": {"type": "onClick"}, "action": {"type": "back"}}));
let mut play = s.doc().unwrap().clone();
play_interactions(&mut play);
let its = &play.node(button).unwrap().interactions;
assert_eq!(its.len(), 2);
assert_eq!((&its[0].trigger, &its[0].actions[..]), (&Trigger::OnClick, &[Action::Back][..]), "the instance's own click");
assert_eq!(its[1].trigger, Trigger::OnPress, "the main's press still plays");
// "Change to" on a layer inside the button changes the button, not the icon instance nested
// in it (the variant belongs to the button's set).
let doc = s.doc().unwrap();
assert_eq!(change_to_target(doc, nested, pressed), Some(button));
assert_eq!(change_to_target(doc, button, pressed), Some(button));
assert_eq!(crate::components::owning_instance(doc, nested), Some(nested), "(the innermost instance would have been the icon)");
// Outside any instance there is nothing to change.
assert_eq!(change_to_target(doc, screen, pressed), None);
// Shown as Pressed it plays Pressed's interactions, also when it holds copies of Default's
// (instances placed by earlier versions copied the main's interactions).
let default_its = s.doc().unwrap().node(default_of(&s, pressed)).unwrap().interactions.clone();
ex(&mut s, "proto.interaction.clear", json!({"ids": [button]}));
ex(&mut s, "instance.setProperties", json!({"ids": [button], "properties": {"State": "Pressed"}}));
let mut play = s.doc().unwrap().clone();
play.get_mut(button).unwrap().interactions = default_its;
play_interactions(&mut play);
let its = &play.node(button).unwrap().interactions;
assert_eq!(its.len(), 1);
assert!(matches!(its[0].actions[..], [Action::ChangeTo { dest }] if dest != pressed), "back to Default: {its:?}");
}
/// The other variant of `variant`'s set.
fn default_of(s: &Session, variant: NodeId) -> NodeId {
let doc = s.doc().unwrap();
let set = crate::components::variant_set(doc, variant).unwrap();
doc.node(set).unwrap().children.iter().copied().find(|v| *v != variant).unwrap()
}
#[test]
fn hotspots_priority_and_click_through() {
let mut s = Session::new();
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300, "name": "A"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 300, "name": "B"})));
let card = idv(&ex(&mut s, "frame.create", json!({"x": 20, "y": 20, "width": 200, "height": 100, "parent": a, "name": "Card"})));
ex(&mut s, "node.set", json!({"id": card, "props": {"clip": true}}));
let rect = |s: &mut Session, name: &str, parent, x: f64, y: f64| {
idv(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 60, "height": 40, "parent": parent, "props": {"name": name}})))
};
let button = rect(&mut s, "Button", card, 30.0, 30.0);
let edge = rect(&mut s, "Edge", card, 190.0, 30.0);
let away = rect(&mut s, "Away", card, 260.0, 30.0);
let hidden = rect(&mut s, "Hidden", a, 30.0, 200.0);
let cover = rect(&mut s, "Cover", a, 30.0, 30.0);
for id in [card, button, edge, away, hidden] {
ex(&mut s, "proto.interaction.add", json!({"id": id, "dest": b}));
}
ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}}));
let doc = s.doc().unwrap();
let hot = hotspots(doc, a);
let ids: Vec<NodeId> = hot.iter().map(|h| h.0).collect();
// Bottom-most first: the player tries them top-most first, so the button (inside the card)
// wins a click over the card, and the card takes clicks on the rest of it.
assert_eq!(ids, [card, button, edge], "nested hotspots, the deepest on top");
// Layers without interactions are not hotspots: the cover over the button doesn't block it
// (docs silent on covered hotspots; pinned: clicks reach the hotspot under it).
assert!(!ids.contains(&cover));
// Hidden layers aren't hotspots; a clipping frame cuts what's outside it.
assert!(!ids.contains(&hidden), "hidden");
assert!(!ids.contains(&away), "clipped away entirely");
let e = hot[2].1;
assert_eq!((e.x0, e.x1), (210.0, 220.0), "the edge layer is clipped to the card (20 + 200)");
}
#[test]
fn scroll_to_keeps_an_offset() {
let mut s = Session::new();
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 900, "name": "A"})));
let item = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 700, "width": 300, "height": 40, "parent": a})));
let add = |s: &mut Session, action: Value| s.execute("proto.interaction.add", &json!({"id": a, "action": action}));
add(&mut s, json!({"type": "scrollTo", "dest": item, "offset": [0, 60]})).unwrap();
add(&mut s, json!({"type": "scrollTo", "dest": item})).unwrap();
let its = &s.doc().unwrap().node(a).unwrap().interactions;
assert_eq!(its[0].actions, [Action::ScrollTo { dest: item, offset: [0.0, 60.0] }]);
assert_eq!(its[1].actions, [Action::ScrollTo { dest: item, offset: [0.0, 0.0] }], "no offset: the layer goes to the top");
// Files only carry an offset that was set.
assert!(serde_json::to_value(&its[1].actions[0]).unwrap().get("offset").is_none());
// Junk is refused, nothing changes.
assert!(add(&mut s, json!({"type": "scrollTo", "dest": item, "offset": "top"})).is_err());
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
}

View File

@ -1,10 +1,11 @@
//! Prototype actions run by the prototype player on its own copy of the document: set variable (a
//! value or an expression), set variable mode, choosing the branch of a conditional action, and
//! where navigating to a section goes, and the hotspots a screen offers.
//! value or an expression), set variable mode, choosing the branch of a conditional action, where
//! navigating to a section goes, which instance "Change to" changes, the interactions instances
//! play (interactive components), and the hotspots a screen offers.
use std::collections::HashMap;
use figcraft_doc::proto::{Action, ConditionalBlock, Expr};
use figcraft_doc::proto::{Action, ConditionalBlock, Expr, Interaction};
use figcraft_doc::{Document, NodeId, NodeKind, VariableType, VariableValue};
use figcraft_geom::Rect;
use serde_json::Value;
@ -109,6 +110,47 @@ pub fn remember_sections(doc: &Document, frame: NodeId, visited: &mut HashMap<No
}
}
/// Interactive components: every instance plays the interactions on its main component's root
/// (the variant it shows), except those with a trigger the instance has interactions of its own
/// for (the instance's win). Interactions an instance holds that equal one on a variant of its
/// set are copies (instances used to copy them when placed) and give way to the variant shown.
/// For the player's copy of the document.
pub fn play_interactions(doc: &mut Document) {
let plays: Vec<(NodeId, Vec<Interaction>)> = doc
.nodes()
.filter_map(|n| {
let NodeKind::Instance { component, .. } = &n.kind else { return None };
let main = doc.node(*component)?;
let variants = crate::components::variant_set(doc, *component).and_then(|s| doc.node(s)).map_or(vec![*component], |s| s.children.clone());
let copied = |it: &Interaction| variants.iter().filter_map(|v| doc.node(*v)).any(|v| v.interactions.contains(it));
let own: Vec<&Interaction> = n.interactions.iter().filter(|it| !copied(it)).collect();
let plays: Vec<Interaction> = own
.iter()
.map(|it| (*it).clone())
.chain(main.interactions.iter().filter(|it| own.iter().all(|o| o.trigger != it.trigger)).cloned())
.collect();
(plays != n.interactions).then_some((n.id, plays))
})
.collect();
for (id, its) in plays {
if let Some(n) = doc.node_mut(id) {
n.interactions = its;
}
}
}
/// The instance "Change to" `dest` changes when it runs on `node`: the nearest of `node` and the
/// instances around it that shows a variant of `dest`'s component set (a button's own icon
/// instance doesn't change), else the innermost instance holding `node`.
pub fn change_to_target(doc: &Document, node: NodeId, dest: NodeId) -> Option<NodeId> {
let set = crate::components::variant_set(doc, dest);
let in_set = |id: &NodeId| match doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => set.is_some() && crate::components::variant_set(doc, *component) == set,
_ => false,
};
std::iter::once(node).chain(doc.ancestors(node)).find(in_set).or_else(|| crate::components::owning_instance(doc, node))
}
/// The hotspots of the screen `root` (a presented frame or overlay): its visible layers with
/// interactions, bottom-most first, with their bounds on the page clipped by the frames around
/// them that clip content (the presenter flashes them after a click where nothing reacts).

View File

@ -3,7 +3,7 @@
//! `prototypeDevice`, `prototypeBackgrounds` and `prototypeStartNodeID`.
use figcraft_doc::proto::{
Action, ConditionalBlock, DeviceRotation, Direction, Easing, Expr, ExprOp, Interaction, MAX_ACTION_DEPTH, MAX_EXPR_DEPTH, Overflow,
Action, ConditionalBlock, DeviceRotation, Direction, Easing, Expr, ExprOp, Interaction, KeyMods, MAX_ACTION_DEPTH, MAX_EXPR_DEPTH, Overflow,
OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
};
use figcraft_doc::{Node, NodeId, VariableType, VariableValue};
@ -179,13 +179,21 @@ impl Ctx<'_> {
return None;
}
let codes: Vec<f64> = list(t, "keyCodes").iter().filter_map(Value::as_f64).collect();
let Some(key) = codes.iter().rev().find(|c| !is_modifier(**c)).and_then(|c| key_name(*c)) else {
let Some(key) = codes.iter().rev().find(|c| modifier(**c).is_none()).and_then(|c| key_name(*c)) else {
self.warn("key triggers without a supported key were skipped");
return None;
};
if codes.iter().any(|c| is_modifier(*c)) {
self.warn("key triggers with modifier keys import as the key alone");
// Modifier keys make it a combination (`Shift+A`).
let mut mods = KeyMods::default();
for m in codes.iter().filter_map(|c| modifier(*c)) {
*match m {
16 => &mut mods.shift,
17 => &mut mods.ctrl,
18 => &mut mods.alt,
_ => &mut mods.cmd,
} = true;
}
let key = mods.combo(&key);
if ty == "ON_KEY_UP" {
self.warn("key up triggers import as key presses");
}
@ -282,7 +290,7 @@ impl Ctx<'_> {
Action::Navigate { dest, preserve_scroll: a.get("preserveScrollPosition").and_then(Value::as_bool).unwrap_or(false) }
}
Some("SWAP") => Action::SwapOverlay { dest },
Some("SCROLL_TO") => Action::ScrollTo { dest },
Some("SCROLL_TO") => Action::ScrollTo { dest, offset: [0.0, 0.0] },
Some("CHANGE_TO") => Action::ChangeTo { dest },
Some("OVERLAY") => {
let meta = text(a, "destinationId").and_then(|d| self.overlay_meta.get(d)).copied();
@ -514,8 +522,9 @@ fn overlay_position(s: &str) -> Option<OverlayPosition> {
}
/// Shift, Control, Alt and the platform keys.
fn is_modifier(code: f64) -> bool {
matches!(code as i64, 16 | 17 | 18 | 91 | 92 | 93 | 224)
/// A modifier key's code (Shift 16, Control 17, Alt 18, Meta 91/92/93/224).
fn modifier(code: f64) -> Option<i64> {
Some(code as i64).filter(|c| matches!(c, 16 | 17 | 18 | 91 | 92 | 93 | 224))
}
/// Our key name for a keyboard key code.

View File

@ -226,7 +226,7 @@ fn interactions_triggers_actions_and_transitions() {
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));
assert_eq!(its[2].trigger, Trigger::KeyPress { key: "A".into() });
assert_eq!(its[2].trigger, Trigger::KeyPress { key: "Shift+A".into() }, "modifier keys make a combination");
assert_eq!(its[2].actions, [Action::Back]);
assert_eq!(its[3].trigger, Trigger::MouseEnter);
assert_eq!(its[3].actions, [Action::OpenUrl { url: "https://example.com".into() }]);
@ -237,7 +237,7 @@ fn interactions_triggers_actions_and_transitions() {
assert_eq!(its[6].trigger, Trigger::OnDrag);
assert_eq!(its[6].actions, [Action::SetVariableMode { collection: THEME.into(), mode: "1:0".into() }]);
assert_eq!(its[7].trigger, Trigger::MouseUp);
assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home") }], "unresolved destinations are dropped");
assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped");
// The legacy single transition.
let details = named(&doc, "Details");
@ -246,7 +246,8 @@ fn interactions_triggers_actions_and_transitions() {
assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..]));
assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn));
for w in ["video triggers", "modifier keys", "without a destination"] {
assert!(!has(&warnings, "modifier keys"), "{warnings:?}");
for w in ["video triggers", "without a destination"] {
assert!(has(&warnings, w), "{w}: {warnings:?}");
}
// Without variables, set-variable actions have nothing to set.

View File

@ -15,6 +15,7 @@ name = "English"
"1…9, 0" = "1…9, 0"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
"About" = "About"
"About FigCraft" = "About FigCraft"
"Action" = "Action"

View File

@ -16,6 +16,7 @@ status = "draft — needs native review"
"1…9, 0" = "1…9, 0"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura"
"About" = "Acerca de"
"About FigCraft" = "Acerca de FigCraft"
"Action" = "Acción"

View File

@ -25,7 +25,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use figcraft_color::{Color, Paint, PaintKind};
use figcraft_doc::proto::{
Action, Device, DeviceColor, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
Action, Device, DeviceColor, Direction, Easing, KeyMods, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
};
use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint};
use figcraft_geom::{Affine, Point};
@ -80,6 +80,9 @@ struct Anim {
start: f64,
dur: f64,
easing: Easing,
/// Started by a drag: while the pointer stays down the transition follows it (where the drag
/// began, the unit vector it moves along and the distance that completes it, screen points).
follow: Option<(Pos2, Vec2, f32)>,
}
/// What a wheel or drag scrolls: a frame with overflow (offsets in page units), or the whole view
@ -449,11 +452,16 @@ impl Presenter {
}
/// Lay the document out for the screen: `design` with the presented frame resized to `fit`
/// when the prototype is responsive (its constraints and auto layout respond).
/// when the prototype is responsive (its constraints and auto layout respond), instances
/// playing their variants' interactions and fixed layers on top.
fn relayout(&mut self) {
let mut d = self.design.clone();
if let (Some(d), Some(size)) = (d.as_mut(), self.fit) {
figcraft_engine::cmd::proto::respond(d, self.current, size);
if let Some(d) = d.as_mut() {
if let Some(size) = self.fit {
figcraft_engine::cmd::proto::respond(d, self.current, size);
}
figcraft_engine::proto_run::play_interactions(d);
raise_fixed(d);
}
self.doc = d;
self.rev += 1;
@ -493,11 +501,13 @@ impl Presenter {
}
/// What "Scroll to" `dest` moves: every scrolling frame around it, innermost first, brings the
/// layer (then the frame holding it) to its top-left as far as it can scroll. Each entry is
/// (frame, offset now, offset after).
fn scroll_to_layer(&self, doc: &Document, dest: NodeId) -> Vec<ScrollMove> {
/// layer (then the frame holding it) to its top-left as far as it can scroll, the innermost
/// `offset` short of it. Each entry is (frame, offset now, offset after).
fn scroll_to_layer(&self, doc: &Document, dest: NodeId, offset: [f64; 2]) -> Vec<ScrollMove> {
let mut out = Vec::new();
let mut target = dest;
// The innermost scrolling frame leaves `offset` before the layer.
let mut off = offset.map(|v| if v.is_finite() { v } else { 0.0 });
for f in doc.ancestors(dest) {
let (rx, ry) = self.range(doc, f);
let Some(tn) = doc.node(target) else { break };
@ -507,9 +517,10 @@ impl Presenter {
let rel = doc.world_transform(f).inverse() * doc.world_transform(target);
let b = figcraft_geom::transform_rect_bbox(rel, tn.local_rect());
let from = self.scroll.get(&f).copied().unwrap_or((0.0, 0.0));
let to = clamp_scroll((if rx > 0.0 { b.x0 } else { from.0 }, if ry > 0.0 { b.y0 } else { from.1 }), (rx, ry));
let to = clamp_scroll((if rx > 0.0 { b.x0 - off[0] } else { from.0 }, if ry > 0.0 { b.y0 - off[1] } else { from.1 }), (rx, ry));
out.push((f, from, to));
target = f;
off = [0.0, 0.0];
}
out
}
@ -577,7 +588,7 @@ impl Presenter {
self.anim = if matches!(transition, Transition::Instant) || dur <= 0.0 {
None
} else {
Some(Anim { from_doc, from_frame: self.current, from_tex, transition, start: now, dur, easing })
Some(Anim { from_doc, from_frame: self.current, from_tex, transition, start: now, dur, easing, follow: None })
};
}
@ -651,7 +662,7 @@ impl Presenter {
/// 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<Rect>, 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 };
let dur = f64::from(it.duration_ms) / 1000.0;
let dur = it.easing.seconds(it.duration_ms);
let temp = match it.trigger {
Trigger::OnHover => Some(false),
Trigger::OnPress => Some(true),
@ -697,8 +708,8 @@ impl Presenter {
None => false,
}
}
Action::ScrollTo { dest } => {
let frames = self.scroll_to_layer(&doc, *dest);
Action::ScrollTo { dest, offset } => {
let frames = self.scroll_to_layer(&doc, *dest, *offset);
if frames.is_empty() {
return false;
}
@ -714,6 +725,7 @@ impl Presenter {
if !doc.contains(*dest) {
return false;
}
self.delays_fired.retain(|(id, _)| !doc.is_ancestor(*dest, *id));
self.overlays.push(OverlayState {
id: *dest,
position: *position,
@ -732,6 +744,7 @@ impl Presenter {
if !doc.contains(*dest) {
return false;
}
self.delays_fired.retain(|(id, _)| !doc.is_ancestor(*dest, *id));
match self.overlays.last_mut() {
Some(o) => {
o.id = *dest;
@ -763,12 +776,7 @@ impl Presenter {
true
}
Action::ChangeTo { dest } => {
let inst = if matches!(doc.node(node).map(|n| &n.kind), Some(NodeKind::Instance { .. })) {
Some(node)
} else {
figcraft_engine::components::owning_instance(&doc, node)
};
let Some(inst) = inst else { return false };
let Some(inst) = figcraft_engine::proto_run::change_to_target(&doc, node, *dest) else { return false };
if !doc.contains(*dest) {
return false;
}
@ -971,7 +979,8 @@ fn lerp_paints(from: &[Paint], to: &mut [Paint], t: f64) {
/// layers only in the source are copied in and fade out. Render `to_root` of the result.
pub fn smart_interpolate(from: &Document, from_root: NodeId, to: &Document, to_root: NodeId, t: f64) -> Document {
let mut out = to.clone();
if !t.is_finite() || t >= 1.0 {
// Springs and back easings go past 1 before they settle: matched layers overshoot too.
if !t.is_finite() || t == 1.0 {
return out;
}
let t = t.max(-1.0);
@ -1240,6 +1249,26 @@ pub fn apply_scroll(doc: &Document, scroll: &HashMap<NodeId, (f64, f64)>) -> Doc
out
}
/// Fixed layers draw (and take clicks) above the content that scrolls under them: each frame's
/// fixed children move to the top of its children, keeping their order (for the player's copy).
pub fn raise_fixed(doc: &mut Document) {
let fixed = |c: &NodeId| doc.node(*c).is_some_and(|c| c.scroll_position == ScrollPosition::Fixed);
let moves: Vec<(NodeId, Vec<NodeId>)> = doc
.nodes()
.filter(|n| !matches!(n.kind, NodeKind::Page { .. }) && n.children.iter().any(fixed))
.filter_map(|n| {
let (top, mut order): (Vec<NodeId>, Vec<NodeId>) = n.children.iter().partition(|c| fixed(c));
order.extend(top);
(order != n.children).then_some((n.id, order))
})
.collect();
for (id, order) in moves {
if let Some(n) = doc.node_mut(id) {
n.children = order;
}
}
}
/// Where the outgoing and incoming frames are drawn at eased progress `t`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FramePlacement {
@ -1352,8 +1381,10 @@ impl Layout {
}
/// The soonest "after delay" interaction in `root`'s subtree that hasn't fired yet, and the seconds
/// until it's due (zero or less: due now).
/// until it's due (zero or less: due now). Timers start when the screen arrives, an overlay's when
/// it opens.
fn next_delay(p: &Presenter, doc: &Document, root: NodeId, now: f64) -> Option<((NodeId, usize), f64)> {
let since = p.overlays.last().filter(|o| o.id == root).map_or(p.arrived, |o| o.opened);
let mut next: Option<((NodeId, usize), f64)> = None;
for id in doc.subtree(root) {
let Some(n) = doc.node(id) else { continue };
@ -1361,7 +1392,7 @@ fn next_delay(p: &Presenter, doc: &Document, root: NodeId, now: f64) -> Option<(
if let Trigger::AfterDelay { ms } = it.trigger
&& !p.delays_fired.contains(&(id, k))
{
let due = p.arrived + f64::from(ms) / 1000.0 - now;
let due = since + f64::from(ms) / 1000.0 - now;
if next.is_none_or(|(_, d)| due < d) {
next = Some(((id, k), due));
}
@ -2094,6 +2125,15 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
)
});
let keys = if keys_on { keys } else { Vec::new() };
// A transition a drag started follows the pointer while it stays down, then plays on from there.
if let Some(a) = p.anim.as_mut()
&& let Some((from, dir, len)) = a.follow
{
match pos.filter(|_| down) {
Some(q) => a.start = now - f64::from(((q - from).dot(dir) / len).clamp(0.0, 1.0)) * a.dur,
None => a.follow = None,
}
}
let pos = pos.filter(|_| over_area || p.press.is_some());
// A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
// past the ends).
@ -2222,10 +2262,24 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
{
p.dragged = true;
let at_start: Vec<(NodeId, Rect)> = hot.iter().rev().filter(|(_, r)| r.contains(start)).copied().collect();
if let Some((id, k, r)) = at_start.iter().find_map(|(id, r)| find_trigger(doc, *id, |t| *t == Trigger::OnDrag).map(|k| (*id, k, *r)))
// A directional transition starts only on a drag its way (else the drag may scroll).
let d = q - start;
let drags =
|id: NodeId| doc.node(id)?.interactions.iter().position(|it| it.trigger == Trigger::OnDrag && it.transition.accepts_drag(d.x, d.y));
if let Some((id, k, r)) = at_start.iter().find_map(|(id, r)| drags(*id).map(|k| (*id, k, *r)))
&& p.fire(id, k, Some(r), now)
{
p.press = None;
// The transition it started follows the pointer: along its direction, else the drag's
// main axis, completing over the screen's size that way.
if let Some(a) = p.anim.as_mut().filter(|a| a.start == now) {
let (x, y) = a
.transition
.direction()
.map_or_else(|| if d.x.abs() >= d.y.abs() { (d.x.signum(), 0.0) } else { (0.0, d.y.signum()) }, direction_vec);
let len = if x != 0.0 { l.screen.width() } else { l.screen.height() };
a.follow = Some((start, vec2(x, y), len.max(1.0)));
}
return;
}
// Otherwise the drag scrolls the innermost frame under it that scrolls, else pans a view
@ -2279,11 +2333,8 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
}
// Keys.
for (key, mods) in &keys {
let name = key.name();
let found = doc.subtree(root).into_iter().find_map(|id| {
find_trigger(doc, id, |t| matches!(t, Trigger::KeyPress { key } if key.eq_ignore_ascii_case(name) || key_alias(key) == Some(name)))
.map(|k| (id, k))
});
let (name, held) = (key.name(), KeyMods { ctrl: mods.ctrl, alt: mods.alt, shift: mods.shift, cmd: mods.mac_cmd });
let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, held)).map(|k| (id, k)));
if let Some((id, k)) = found {
if p.fire(id, k, None, now) {
consume_key(ctx, *key);
@ -2335,23 +2386,6 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
}
}
/// Alternative spellings people type for keys.
fn key_alias(k: &str) -> Option<&'static str> {
Some(match k.to_ascii_lowercase().as_str() {
"space" | " " => "Space",
"return" | "enter" => "Enter",
"esc" | "escape" => "Escape",
"left" | "arrowleft" | "←" => "ArrowLeft",
"right" | "arrowright" | "→" => "ArrowRight",
"up" | "arrowup" | "↑" => "ArrowUp",
"down" | "arrowdown" | "↓" => "ArrowDown",
"tab" => "Tab",
"backspace" => "Backspace",
"delete" => "Delete",
_ => return None,
})
}
fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, now: f64) {
let ppp = f64::from(ctx.pixels_per_point());
let px = l.scale * ppp;
@ -2475,6 +2509,10 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay
#[path = "present_variables_tests.rs"]
mod variables_tests;
#[cfg(test)]
#[path = "present_behaviour_tests.rs"]
mod behaviour_tests;
#[cfg(test)]
mod tests {
use super::*;
@ -2688,13 +2726,13 @@ mod tests {
fn scroll_to_moves_every_scrolling_frame_around_the_layer() {
let (d, a, _, _, list, item) = device_doc();
let mut p = player_on(d.clone(), a);
assert!(p.perform(&Action::ScrollTo { dest: item }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0));
assert!(p.perform(&Action::ScrollTo { dest: item, offset: [0.0, 0.0] }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0));
// The list brings the item to its top (as far as it scrolls), the screen brings the list.
assert_eq!(p.scroll[&list], (0.0, 500.0));
assert_eq!(p.scroll[&a], (0.0, 1148.0));
// Animated, it gets there over the duration.
let mut p = player_on(d, a);
assert!(p.perform(&Action::ScrollTo { dest: list }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0));
assert!(p.perform(&Action::ScrollTo { dest: list, offset: [0.0, 0.0] }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0));
let anim = p.scroll_anim.as_ref().unwrap();
assert_eq!(anim.frames, vec![(a, (0.0, 0.0), (0.0, 1148.0))]);
}

View File

@ -0,0 +1,481 @@
//! Prototype player behaviour, written from the public documentation of prototype triggers,
//! actions, overlays, animations and scrolling. Each case states the rule, what it implies and a
//! one-line derivation; where the documentation is silent the case says so and pins what FigCraft
//! does. Gestures go through egui headless (the harness is `present_tests`'); the rest drives the
//! player directly. Model rules (drag directions, key combinations, easing, interactive
//! components, hotspots) are in the engine's `proto_behaviour_tests`.
use std::sync::Arc;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
use figcraft_doc::proto::{Action, Direction, Easing, OverlayPosition, Transition};
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_engine::Session;
use serde_json::{Value, json};
use super::{Presenter, next_delay, raise_fixed, smart_interpolate};
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, name: &str, 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, "props": {"name": name}})))
}
fn on(s: &mut Session, layer: NodeId, trigger: Value, action: Value, transition: Value, ms: u32) {
ex(s, "proto.interaction.add", json!({"id": layer, "trigger": trigger, "action": action, "transition": transition, "durationMs": ms}));
}
fn presenter(u: &Harness) -> Value {
u.app.inspect()["presenter"].clone()
}
fn screen_name(u: &Harness) -> String {
presenter(u)["name"].as_str().unwrap_or_default().to_string()
}
/// The centre of the presenter's hotspot on `layer`.
fn hotspot(u: &Harness, layer: NodeId) -> Pos2 {
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<f32> = h["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect();
pos2((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)
}
fn button(at: Pos2, pressed: bool, modifiers: Modifiers) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }
}
/// What the pointer or the keyboard does over a layer.
#[derive(Clone, Copy, Debug)]
enum Gesture {
Click,
/// Press, move by (dx, dy) in five steps, release.
Drag(f32, f32),
/// Press and keep the button down.
Hold,
HoldRelease,
Hover,
/// Hover, then move off the layer.
HoverLeave,
Key(Key, Modifiers),
/// Wait (seconds) with the pointer away.
Wait(f64),
}
impl Gesture {
fn play(self, u: &mut Harness, at: Pos2) {
let none = Modifiers::NONE;
let away = pos2(at.x - 40.0, at.y - 40.0);
u.frame(vec![Event::PointerMoved(if matches!(self, Gesture::Wait(_)) { away } else { at })]);
match self {
Gesture::Click => {
u.frame(vec![button(at, true, none)]);
u.frame(vec![button(at, false, none)]);
}
Gesture::Drag(dx, dy) => {
u.frame(vec![button(at, true, none)]);
for k in 1..=5 {
u.frame(vec![Event::PointerMoved(at + vec2(dx, dy) * (k as f32 / 5.0))]);
}
u.frame(vec![button(at + vec2(dx, dy), false, none)]);
}
Gesture::Hold | Gesture::HoldRelease => {
u.frame(vec![button(at, true, none)]);
u.frames(2, Vec::new);
if matches!(self, Gesture::HoldRelease) {
u.frame(vec![button(at, false, none)]);
}
}
Gesture::Hover => u.frames(2, Vec::new),
Gesture::HoverLeave => {
u.frames(2, Vec::new);
u.frame(vec![Event::PointerMoved(away)]);
}
Gesture::Key(key, modifiers) => {
let k = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers };
u.frame(vec![k(true), k(false)]);
}
Gesture::Wait(secs) => u.frames((secs * 60.0).round() as usize, Vec::new),
}
u.frames(2, Vec::new);
}
}
/// Screens A (300×400, presented) and B; a 100×60 layer "Hot" at (50, 50) in A whose one
/// interaction (`trigger`, `transition`) navigates to B.
fn one_interaction(trigger: Value, transition: Value, ms: u32) -> (Harness, NodeId) {
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, "Hot", a, [50.0, 50.0, 100.0, 60.0]);
on(&mut s, hot, trigger, json!({"type": "navigate", "dest": b}), transition, ms);
let mut u = Harness::new(s);
u.run("proto.present", json!({"start": a}));
(u, hot)
}
#[test]
fn triggers_fire_on_their_gesture() {
let instant = json!({"type": "instant"});
let push_left = json!({"type": "push", "direction": "left"});
let t = |ty: &str| json!({"type": ty});
let key = |k: &str| json!({"type": "keyPress", "key": k});
let (none, shift) = (Modifiers::NONE, Modifiers::SHIFT);
// (trigger, animation, gesture on the layer, screen after, derivation)
let cases = [
(t("onClick"), &instant, Gesture::Click, "B", "on click: press and release on the layer"),
(t("onClick"), &instant, Gesture::Drag(40.0, 0.0), "A", "a drag is not a click"),
(t("onDrag"), &instant, Gesture::Drag(40.0, 0.0), "B", "on drag: a drag that starts on the layer"),
(t("onDrag"), &instant, Gesture::Click, "A", "a click is not a drag"),
(t("onDrag"), &push_left, Gesture::Drag(-60.0, 0.0), "B", "on drag with push left: a drag to the left"),
(t("onDrag"), &push_left, Gesture::Drag(60.0, 0.0), "A", "a drag against the animation's direction doesn't start it"),
(t("mouseDown"), &instant, Gesture::Hold, "B", "mouse down: as the button goes down"),
(t("mouseUp"), &instant, Gesture::Hold, "A", "mouse up: not before the button comes up"),
(t("mouseUp"), &instant, Gesture::Click, "B", "mouse up: when it does"),
(t("onPress"), &instant, Gesture::Hold, "B", "while pressing: shown while the button is down"),
(t("onPress"), &instant, Gesture::HoldRelease, "A", "while pressing: reverts on release"),
(t("onHover"), &instant, Gesture::Hover, "B", "while hovering: shown while the pointer is over the layer"),
(t("onHover"), &instant, Gesture::HoverLeave, "A", "while hovering: reverts when the pointer leaves"),
(t("mouseEnter"), &instant, Gesture::HoverLeave, "B", "mouse enter: fires on entering and doesn't revert"),
(t("mouseLeave"), &instant, Gesture::Hover, "A", "mouse leave: not while the pointer is still over it"),
(t("mouseLeave"), &instant, Gesture::HoverLeave, "B", "mouse leave: when it leaves"),
(key("K"), &instant, Gesture::Key(Key::K, none), "B", "key: pressed anywhere on the screen"),
(key("Shift+K"), &instant, Gesture::Key(Key::K, none), "A", "a combination needs its modifier"),
(key("Shift+K"), &instant, Gesture::Key(Key::K, shift), "B", "the combination"),
(json!({"type": "afterDelay", "ms": 500}), &instant, Gesture::Wait(0.3), "A", "after delay: not before the delay"),
(json!({"type": "afterDelay", "ms": 500}), &instant, Gesture::Wait(0.6), "B", "after delay: timed from when the screen shows"),
];
for (trigger, transition, gesture, want, why) in cases {
let (mut u, hot) = one_interaction(trigger.clone(), transition.clone(), 300);
let at = hotspot(&u, hot);
gesture.play(&mut u, at);
assert_eq!(screen_name(&u), want, "{trigger} {transition} {gesture:?}: {why}");
}
}
/// A (presented) holding a 200×150 frame "Parent" at (50, 50) that goes to C on click, with a
/// 60×40 "Child" inside it at (20, 20) going to B on `child` (a trigger; None: no interaction),
/// optionally hidden, and optionally a "Cover" without interactions over the child. The screen a
/// click on the child's middle shows.
fn click_on_child(child: Option<Value>, hidden: bool, cover: bool) -> String {
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 c = frame(&mut s, "C", 800.0, 300.0, 400.0);
let parent = id(&ex(&mut s, "frame.create", json!({"x": 50, "y": 50, "width": 200, "height": 150, "parent": a, "name": "Parent"})));
let kid = rect(&mut s, "Child", parent, [20.0, 20.0, 60.0, 40.0]);
on(&mut s, parent, json!({"type": "onClick"}), json!({"type": "navigate", "dest": c}), json!({"type": "instant"}), 0);
if let Some(t) = child {
on(&mut s, kid, t, json!({"type": "navigate", "dest": b}), json!({"type": "instant"}), 0);
}
if hidden {
ex(&mut s, "node.set", json!({"id": kid, "props": {"visible": false}}));
}
if cover {
rect(&mut s, "Cover", a, [60.0, 60.0, 100.0, 60.0]);
}
let mut u = Harness::new(s);
u.run("proto.present", json!({"start": a}));
// The child's middle: the parent's hotspot's top-left + (20 + 30, 20 + 20).
let hot = presenter(&u)["hotspots"].as_array().cloned().unwrap_or_default();
let p = hot.iter().find(|h| h["id"] == json!(parent)).unwrap();
let at = pos2(p["rect"][0].as_f64().unwrap() as f32 + 50.0, p["rect"][1].as_f64().unwrap() as f32 + 40.0);
Gesture::Click.play(&mut u, at);
screen_name(&u)
}
#[test]
fn nested_hotspots_and_click_through() {
// (child's trigger, child hidden, a cover on top, screen after a click on the child, derivation)
let cases = [
(Some(json!({"type": "onClick"})), false, false, "B", "the top-most layer with the trigger wins over its parent"),
(Some(json!({"type": "keyPress", "key": "X"})), false, false, "C", "a layer without that trigger lets the click through to the parent's"),
(None, false, false, "C", "a layer without interactions: the parent's"),
(Some(json!({"type": "onClick"})), false, true, "B", "a layer without interactions over a hotspot doesn't block it (docs silent; pinned)"),
(Some(json!({"type": "onClick"})), true, false, "C", "hidden layers aren't hotspots"),
];
for (child, hidden, cover, want, why) in cases {
assert_eq!(click_on_child(child.clone(), hidden, cover), want, "{child:?} hidden={hidden} cover={cover}: {why}");
}
}
fn player(doc: Document, start: NodeId) -> Presenter {
let mut p = Presenter::new(start);
p.sync(&Arc::new(doc), 0.0);
p
}
fn act(p: &mut Presenter, a: Action, now: f64) -> bool {
let node = p.current;
p.perform(&a, node, Transition::Instant, 0.0, Easing::Linear, None, now)
}
fn open(dest: NodeId, close_on_outside: bool) -> Action {
Action::OpenOverlay { dest, position: OverlayPosition::Center, close_on_outside, background: None }
}
/// Screens A and B and three overlay frames M1, M2, M3 (all top-level).
fn screens() -> (Document, [NodeId; 5]) {
let mut s = Session::new();
let ids = [("A", 0.0), ("B", 400.0), ("M1", 800.0), ("M2", 1000.0), ("M3", 1200.0)].map(|(n, x)| frame(&mut s, n, x, 150.0, 100.0));
(s.doc().unwrap().clone(), ids)
}
#[test]
fn overlays_stack_swap_close_and_go_with_navigation() {
let (doc, [a, b, m1, m2, m3]) = screens();
let mut p = player(doc, a);
let shown = |p: &Presenter| p.overlays.iter().map(|o| o.id).collect::<Vec<_>>();
// (action, overlays after, screen after, derivation)
let steps = [
(open(m1, true), vec![m1], a, "open overlay: over the screen"),
(open(m2, true), vec![m1, m2], a, "an overlay opened from an overlay stacks on it"),
(Action::SwapOverlay { dest: m3 }, vec![m1, m3], a, "swap overlay replaces the top overlay"),
(Action::Back, vec![m1], a, "back closes the top overlay first"),
(Action::CloseOverlay, vec![], a, "close overlay closes it"),
(open(m1, true), vec![m1], a, "(again)"),
(
Action::Navigate { dest: b, preserve_scroll: false },
vec![],
b,
"navigating from an overlay replaces the screen under it and closes the overlays",
),
(Action::Back, vec![], a, "back returns to the screen before"),
];
for (k, (action, overlays, screen, why)) in steps.into_iter().enumerate() {
assert!(act(&mut p, action.clone(), k as f64), "{action:?}: {why}");
assert_eq!((shown(&p), p.current), (overlays, screen), "{action:?}: {why}");
}
assert!(!act(&mut p, Action::Back, 10.0), "nothing left to go back to");
assert!(!act(&mut p, Action::CloseOverlay, 10.0), "no overlay to close");
}
#[test]
fn a_click_outside_an_overlay_closes_it_unless_turned_off() {
// (close on click outside, overlays left after a click outside it, derivation)
for (close, left, why) in [(true, 0, "closes on a click outside (the default)"), (false, 1, "turned off: stays open")] {
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, 100.0, 100.0);
let hot = rect(&mut s, "Hot", a, [10.0, 10.0, 60.0, 40.0]);
on(
&mut s,
hot,
json!({"type": "onClick"}),
json!({"type": "openOverlay", "dest": menu, "closeOnOutside": close}),
json!({"type": "instant"}),
0,
);
let mut u = Harness::new(s);
u.run("proto.present", json!({"start": a}));
let at = hotspot(&u, hot);
Gesture::Click.play(&mut u, at);
assert_eq!(presenter(&u)["overlays"], json!([menu]));
// The overlay is centred on A; its top-left corner area is outside it.
Gesture::Click.play(&mut u, at);
assert_eq!(presenter(&u)["overlays"].as_array().map(Vec::len), Some(left), "{why}");
}
}
#[test]
fn an_overlays_after_delay_counts_from_when_it_opens() {
let mut s = Session::new();
let a = frame(&mut s, "A", 0.0, 300.0, 400.0);
let toast = frame(&mut s, "Toast", 400.0, 200.0, 50.0);
on(&mut s, toast, json!({"type": "afterDelay", "ms": 800}), json!({"type": "closeOverlay"}), json!({"type": "instant"}), 0);
let mut p = player(s.doc().unwrap().clone(), a);
let due = |p: &Presenter, now: f64| next_delay(p, p.doc.as_ref().unwrap(), toast, now).map(|(k, d)| (k, (d * 1000.0).round() / 1000.0));
// Opened 5 s after A arrived: the overlay is the screen shown, so its timer starts then.
assert!(act(&mut p, open(toast, true), 5.0));
let (key, d) = due(&p, 5.0).unwrap();
assert_eq!(d, 0.8, "due 0.8 s after it opens, not at once");
p.delays_fired.insert(key);
assert!(act(&mut p, Action::CloseOverlay, 5.8));
// Each time it opens its timer starts again.
assert!(act(&mut p, open(toast, true), 10.0));
assert_eq!(due(&p, 10.5).map(|x| x.1), Some(0.3));
}
#[test]
fn back_plays_the_way_in_backwards_and_restart_starts_over() {
let (doc, [a, b, c, ..]) = screens();
let mut p = player(doc, a);
let go = |p: &mut Presenter, dest, tr, now| {
let node = p.current;
p.perform(&Action::Navigate { dest, preserve_scroll: false }, node, tr, 0.3, Easing::Linear, None, now)
};
assert!(go(&mut p, b, Transition::Push { direction: Direction::Left }, 1.0));
assert!(go(&mut p, c, Transition::MoveIn { direction: Direction::Top }, 2.0));
assert_eq!(p.history.iter().map(|v| v.frame).collect::<Vec<_>>(), [a, b], "history: the screens left, in order");
// (screen after Back, animation it plays, derivation)
for (screen, tr, why) in [
(b, Transition::MoveOut { direction: Direction::Bottom }, "back from C: C moves out the way it came"),
(a, Transition::Push { direction: Direction::Right }, "back from B: pushed back to the right"),
] {
assert!(act(&mut p, Action::Back, 3.0));
assert_eq!((p.current, p.anim.as_ref().map(|x| x.transition)), (screen, Some(tr)), "{why}");
}
go(&mut p, b, Transition::Instant, 4.0);
p.restart(a, 5.0);
assert!(p.history.is_empty() && p.current == a, "restart: the starting screen, no history");
}
#[test]
fn springs_play_until_they_settle() {
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, "Hot", a, [10.0, 10.0, 60.0, 40.0]);
// (easing, how long the animation plays (s), derivation)
let cases = [
(json!("easeOut"), 0.3, "a curve plays for the duration set"),
(json!("bouncy"), Easing::Bouncy.seconds(300), "a spring plays until it settles, whatever the duration"),
(json!({"customSpring": {"mass": 1, "stiffness": 100, "damping": 20}}), 0.92, "custom springs too (critically damped: ~9.2 / √(k/m) s)"),
];
for (k, (easing, secs, why)) in cases.iter().enumerate() {
ex(&mut s, "proto.interaction.add", json!({"id": hot, "dest": b, "transition": {"type": "dissolve"}, "durationMs": 300, "easing": easing}));
let mut p = player(s.doc().unwrap().clone(), a);
assert!(p.fire(hot, k, None, 1.0));
let dur = p.anim.as_ref().map(|x| x.dur).unwrap_or_default();
assert!((dur - secs).abs() < 0.02, "{easing}: {dur} s: {why}");
}
}
#[test]
fn scroll_to_brings_the_layer_to_the_top_less_its_offset() {
let mut s = Session::new();
let a = frame(&mut s, "A", 0.0, 300.0, 600.0);
ex(&mut s, "node.set", json!({"id": a, "props": {"overflow": "vertical"}}));
rect(&mut s, "Long", a, [0.0, 0.0, 300.0, 2000.0]);
let item = rect(&mut s, "Item", a, [0.0, 1200.0, 300.0, 40.0]);
let doc = s.doc().unwrap().clone();
// (offset, scroll after, derivation): A scrolls 2000 − 600 = 1400.
let cases = [
([0.0, 0.0], 1200.0, "no offset: the layer at the top"),
([0.0, 60.0], 1140.0, "60 down from the top (under a 60 high fixed header)"),
([0.0, -50.0], 1250.0, "a negative offset scrolls past it"),
([0.0, 5000.0], 0.0, "never before the start"),
([0.0, -5000.0], 1400.0, "nor past the end"),
([f64::NAN, f64::INFINITY], 1200.0, "junk offsets count as none"),
];
for (offset, want, why) in cases {
let mut p = player(doc.clone(), a);
assert!(act(&mut p, Action::ScrollTo { dest: item, offset }, 1.0));
assert_eq!(p.scroll.get(&a).copied(), Some((0.0, want)), "{offset:?}: {why}");
}
}
#[test]
fn fixed_layers_stay_above_the_content_that_scrolls() {
let mut s = Session::new();
let a = frame(&mut s, "A", 0.0, 300.0, 600.0);
ex(&mut s, "node.set", json!({"id": a, "props": {"overflow": "vertical"}}));
let header = rect(&mut s, "Header", a, [0.0, 0.0, 300.0, 60.0]);
let feed = rect(&mut s, "Feed", a, [0.0, 0.0, 300.0, 2000.0]);
let b = frame(&mut s, "B", 400.0, 300.0, 600.0);
for layer in [header, feed] {
on(&mut s, layer, json!({"type": "onClick"}), json!({"type": "navigate", "dest": b}), json!({"type": "instant"}), 0);
}
ex(&mut s, "node.set", json!({"id": header, "props": {"scrollPosition": "fixed"}}));
let mut p = player(s.doc().unwrap().clone(), a);
let shown = p.display().unwrap();
// The header was below the feed in the layer list; fixed, it draws (and takes clicks) on top.
assert_eq!(shown.node(a).unwrap().children, [feed, header], "fixed layers draw above scrolling ones");
assert_eq!(figcraft_engine::proto_run::hotspots(&shown, a).last().map(|h| h.0), Some(header), "the top-most hotspot");
assert_eq!(p.design.as_ref().unwrap().node(a).unwrap().children, [header, feed], "the file's layer order doesn't change");
// Nothing fixed: nothing moves.
let mut d = s.doc().unwrap().clone();
d.get_mut(header).unwrap().scroll_position = Default::default();
raise_fixed(&mut d);
assert_eq!(d.node(a).unwrap().children, [header, feed]);
}
#[test]
fn smart_animate_tweens_matched_layers_and_overshoots_with_the_easing() {
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);
rect(&mut s, "Box", a, [0.0, 0.0, 20.0, 20.0]);
let to = rect(&mut s, "Box", b, [100.0, 0.0, 20.0, 20.0]);
let gone = rect(&mut s, "Dot", b, [0.0, 100.0, 10.0, 10.0]);
ex(&mut s, "node.set", json!({"id": gone, "props": {"visible": false}}));
let doc = s.doc().unwrap().clone();
// (eased progress, x of Box in B, derivation): matched by name and hierarchy, from x 0 to 100.
let cases = [
(0.0, 0.0, "starts where the layer was"),
(0.5, 50.0, "half way"),
(1.1, 110.0, "a spring or back easing past 1 carries the layer past its end"),
(-0.1, -10.0, "and before its start (ease in back)"),
(1.0, 100.0, "ends exactly where it is in the destination"),
];
for (t, x, why) in cases {
let mid = smart_interpolate(&doc, a, &doc, b, t);
let bx = mid.world_bounds(to).unwrap().x0 - mid.world_bounds(b).unwrap().x0;
assert!((bx - x).abs() < 1e-6, "t={t}: {bx} ≠ {x}: {why}");
// Visibility doesn't tween (docs silent; pinned: the destination's).
assert!(!mid.node(gone).unwrap().visible);
}
}
#[test]
fn interactive_components_change_in_place() {
let mut s = Session::new();
let mut variants = Vec::new();
for (k, state) in ["Off", "On"].iter().enumerate() {
let f =
id(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 600, "width": 60, "height": 30, "name": format!("State={state}")})));
rect(&mut s, "Knob", f, [if k == 0 { 0.0 } else { 30.0 }, 0.0, 30.0, 30.0]);
variants.push(id(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
let (off, on_) = (variants[0], variants[1]);
on(&mut s, off, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": on_}), json!({"type": "smartAnimate"}), 200);
on(&mut s, on_, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": off}), json!({"type": "smartAnimate"}), 200);
let screen = frame(&mut s, "Screen", 0.0, 300.0, 400.0);
let toggle = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen})));
let mut p = player(s.doc().unwrap().clone(), screen);
let shows = |p: &Presenter| match p.doc.as_ref().and_then(|d| d.node(toggle)).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => Some(*component),
_ => None,
};
// The instance plays its variant's click (it has none of its own) and changes in place, with
// the variant's animation; the next click plays the other variant's.
for (k, want, why) in [(1.0, on_, "Off's click: changes to On"), (2.0, off, "On's click: back to Off")] {
assert!(p.fire(toggle, 0, None, k), "{why}");
assert_eq!((shows(&p), p.current), (Some(want), screen), "{why}");
assert_eq!(p.anim.as_ref().map(|x| x.transition), Some(Transition::SmartAnimate), "{why}");
}
}
#[test]
fn a_drag_started_transition_follows_the_pointer() {
let (mut u, hot) = one_interaction(json!({"type": "onDrag"}), json!({"type": "push", "direction": "left"}), 400);
let at = hotspot(&u, hot);
let progress = |u: &Harness| {
let a = u.app.ui.present.as_ref().and_then(|p| p.anim.as_ref())?;
Some(((u.time - a.start) / a.dur * 100.0).round() / 100.0)
};
u.frame(vec![Event::PointerMoved(at)]);
u.frame(vec![button(at, true, Modifiers::NONE)]);
// 30 points to the left over a 300 wide screen: a tenth of the way, for as long as it's held.
for k in 1..=3 {
u.frame(vec![Event::PointerMoved(at - vec2(10.0 * k as f32, 0.0))]);
}
assert_eq!(screen_name(&u), "B");
assert_eq!(progress(&u), Some(0.1));
u.frames(5, Vec::new);
assert_eq!(progress(&u), Some(0.1), "held still: it waits");
u.frame(vec![Event::PointerMoved(at - vec2(150.0, 0.0))]);
assert_eq!(progress(&u), Some(0.5), "half the screen: half way");
// Let go, it plays on from there to the end.
u.frame(vec![button(at - vec2(150.0, 0.0), false, Modifiers::NONE)]);
u.frames(30, Vec::new);
assert_eq!(progress(&u), None, "finished");
}

View File

@ -4,7 +4,9 @@
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::Paint;
use figcraft_doc::proto::{Action, ConditionalBlock, Direction, Easing, Interaction, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger};
use figcraft_doc::proto::{
Action, ConditionalBlock, Direction, Easing, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger,
};
use figcraft_doc::*;
use serde_json::{Value, json};
@ -211,7 +213,7 @@ pub(crate) fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId,
"Scroll to" => {
let root = own?;
let dest = doc.subtree(root).into_iter().skip(1).find(|i| *i != node)?;
Action::ScrollTo { dest }
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 },
@ -722,15 +724,17 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
let _ = ui.interact(r, id, Sense::focusable_noninteractive());
let pressed = ui.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, .. } => Some(*key),
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
});
if let Some(kk) = pressed {
if let Some((kk, m)) = pressed {
ui.ctx().data_mut(|d| d.remove::<bool>(capture_id()));
ui.memory_mut(|m| m.surrender_focus(id));
if kk != egui::Key::Escape || key.is_empty() {
patch(app, node, k, json!({"trigger": {"type": "keyPress", "key": kk.name()}}));
// Held modifiers make it a combination (`Shift+K`).
let combo = KeyMods { ctrl: m.ctrl, alt: m.alt, shift: m.shift, cmd: m.mac_cmd }.combo(kk.name());
patch(app, node, k, json!({"trigger": {"type": "keyPress", "key": combo}}));
}
}
} else if resp.hovered() {
@ -804,9 +808,28 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
let vs = variants_for(doc, node);
dest_dropdown(app, ui, doc, &vs, Some(*dest), tr("Variant"), |d| Action::ChangeTo { dest: d }, slot);
}
Action::ScrollTo { dest } => {
Action::ScrollTo { dest, offset } => {
let (dest, offset) = (*dest, *offset);
let layers: Vec<NodeId> = doc.top_level_frame(node).map(|f| doc.subtree(f).into_iter().skip(1).take(400).collect()).unwrap_or_default();
dest_dropdown(app, ui, doc, &layers, Some(*dest), tr("Scroll to layer"), |d| Action::ScrollTo { dest: d }, slot);
dest_dropdown(app, ui, doc, &layers, Some(dest), tr("Scroll to layer"), move |d| Action::ScrollTo { dest: d, offset }, slot);
// Where the layer ends up from the top-left of what scrolls (room for a fixed header).
widgets::sublabels(ui, tr("Offset"), None);
row(ui, 32.0, |ui| {
let w = ((ui.available_width() - 6.0) / 2.0).max(36.0);
for (i, (name, key)) in [("X", "proto_scroll_x"), ("Y", "proto_scroll_y")].into_iter().enumerate() {
let Some(e) = widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0) else { continue };
let v = match e {
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v,
};
if v.is_finite() {
let mut o = offset;
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::ScrollTo { dest, offset: o });
}
}
});
}
Action::OpenUrl { url } => {
widgets::sublabels(ui, tr("Link"), None);
@ -958,7 +981,13 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In
}
}
});
if let Some(e) = widgets::number_field(ui, Id::new(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0)
if it.easing.is_spring() {
// A spring plays until it settles: its duration follows from its settings.
let ms = format!("{:.0} ms", it.easing.seconds(it.duration_ms) * 1000.0);
ui.label(egui::RichText::new(ms).font(regular(11.0)).color(t.text_secondary))
.tip(tr("A spring plays until it settles: its mass, stiffness and damping set how long"));
} else if let Some(e) =
widgets::number_field(ui, Id::new(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0)
{
match e {
FieldEdit::Set(v) => {
@ -987,7 +1016,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In
let (r, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
let box_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + 4.0), pos2(r.max.x - PAD, r.max.y - 4.0));
ui.painter().rect_filled(box_r, CornerRadius::same(5), t.field);
let period = (f64::from(it.duration_ms) / 1000.0).max(0.05) + CURVE_HOLD;
let period = it.easing.seconds(it.duration_ms).max(0.05) + CURVE_HOLD;
let elapsed = curve_play(ui, Id::new(("proto_curve", node, k)), Id::new((format!("{:?}", it.easing), it.duration_ms)), resp.hovered());
let phase = if resp.hovered() || elapsed < period * CURVE_PLAYS {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(33));

View File

@ -266,7 +266,15 @@ right, left) and `oneSided`.
### Prototypes
Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep
the scroll position with `preserveScroll: true`). Variable actions: `{type: "setVariable",
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,
Alt, Shift and Cmd, all held and no others). In the player, an "on drag" trigger with a move, push
or slide animation starts only for a drag that way, and the animation follows the pointer until it
lets go; spring easings play until they settle (their duration follows from the spring, not
`durationMs`); an overlay's after-delay timer starts when it opens; instances of a component play
the interactions on the variant they show unless they have their own for that trigger; fixed
layers draw above the content scrolling under them. Variable actions: `{type: "setVariable",
variable, value}` sets a value, or with `expr` an expression computed when it runs;
`{type: "setVariableMode", collection, mode}` switches a collection's mode for the prototype (ids or
names; modes chosen on layers inside the screen still win); `{type: "conditional", blocks:

View File

@ -1,17 +1,67 @@
# Prototyping parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; triggers, actions, transitions and easings counted from `crates/doc/src/proto.rs`) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (behaviour suite written from the public documentation, every rule's status; fixes and the missing setting it found) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~88%, ready ~70% (estimated).** Every Figma trigger except media and gamepad, every
action except media control, every transition and easing exist (measured from the model enums on
2026-10-10). Not yet checked against larger real prototypes. Remaining: 5–9 h.
**Breadth ~90%, ready ~74% (estimated).** Every Figma trigger except media and gamepad, every
action except media control, every transition and easing exist. Every rule of the public
documentation on triggers, actions, overlays, animations, easing, scrolling and interactive
components is a test case with what it implies (`crates/engine/src/proto_behaviour_tests.rs`, 7
tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 12 tests driving the
player, 21 gestures among them), and all pass. Not yet checked against larger real prototypes.
Remaining: 3–6 h.
| Group | Figma | FigCraft | Missing |
|---|---|---|---|
| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress | gamepad, media triggers |
| Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo, OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions |
| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys and combinations) | gamepad, media triggers, delays on mouse enter/leave/down/up |
| Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions |
| Transitions | Instant, dissolve, smart animate, move in/out, push, slide in/out | all 8 | — |
| Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 | — |
| Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 (springs physical) | — |
## Rules from the documentation
Status: **pass** (a case in the suite holds), **fixed** (the suite found a divergence, now fixed),
**added** (the setting was missing and now exists), **silent** (the documentation doesn't say; the
case pins the current behaviour).
| Rule | Status | Evidence / notes |
|---|---|---|
| On click: press and release on the layer; a drag is not a click | pass | `triggers_fire_on_their_gesture` |
| On drag: a drag that starts on the layer; a click is not a drag | pass | same |
| On drag with a directional animation (move, push, slide): only a drag that way starts it; a drag another way can scroll instead | **fixed** | any drag started it; `on_drag_starts_only_a_drag_its_animation_goes` (exactly diagonal counts for both axes: silent) |
| On drag: the animation it starts follows the pointer while it stays down, then plays on from there | **fixed** | it played by time; `a_drag_started_transition_follows_the_pointer` |
| Mouse down / mouse up: as the button goes down / comes up | pass | `triggers_fire_on_their_gesture` |
| While pressing: shown while the button is down, reverts on release | pass | same |
| While hovering: shown while the pointer is over the layer, reverts when it leaves; mouse enter fires once and doesn't revert; mouse leave fires on leaving | pass | same |
| Key/gamepad: a key or a combination of keys, pressed anywhere on the screen | **fixed** | combinations never fired (`Shift+K` didn't match) and `K` also fired for Shift+K; the editor's recorder now records the modifiers held, and REST files' modifier key codes import as a combination (they imported as the key alone); `key_triggers_fire_for_their_key_or_combination` |
| After delay: timed from when the screen shows | pass | `triggers_fire_on_their_gesture` |
| After delay on an overlay: timed from when the overlay opens, again each time it opens | **fixed** | timed from when the screen under it arrived (a toast opened late closed at once) and fired only the first time; `an_overlays_after_delay_counts_from_when_it_opens` |
| Nested layers with the same trigger: the top-most layer's interaction wins; a layer without that trigger lets it through to the one under it | pass | `nested_hotspots_and_click_through`, `hotspots_priority_and_click_through` |
| A layer without interactions over a hotspot | silent | it doesn't block it: clicks reach the hotspot under it |
| Hidden layers aren't hotspots; clipping frames cut their hotspots | pass | same |
| Navigate to: replaces the screen; from an overlay it closes the overlays; preserve scroll position keeps the offset (as far as the destination scrolls) | pass | `overlays_stack_swap_close_and_go_with_navigation`, `navigating_resets_scroll_unless_it_preserves_it` |
| Back: closes the top overlay first, else returns to the previous screen playing its animation backwards; restart empties the history | pass | `back_plays_the_way_in_backwards_and_restart_starts_over`, `back_and_reverting_play_the_animation_the_other_way` |
| Scroll to: every frame around the layer scrolls to bring it in, animated with the interaction's animation | pass | `scroll_to_moves_every_scrolling_frame_around_the_layer` |
| 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 |
| 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` |
| Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead |
| Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` |
| Smart animate: layers matched by name and hierarchy tween position, size, rotation, opacity, solid fills and strokes and corner radius; unmatched layers dissolve | pass | `smart_animate_tweens_matched_layers_and_overshoots_with_the_easing`, `smart_interpolate_tweens_matched_and_fades_the_rest` |
| Smart animate with a spring or back easing overshoots (and pulls back) | **fixed** | layers snapped to their end once the curve passed 1 |
| Visibility, text content, gradients and images in smart animate | silent | they show the destination's |
| Move in/out, push, slide in/out move the way their direction names; reversed they go the other way | pass | `back_and_reverting_play_the_animation_the_other_way`, `transitions_move_the_right_way` |
| Easing curves: linear, ease in, ease out, ease in and out, back variants, custom bezier | pass | `easing_curves_and_springs` |
| Spring presets are physical springs (mass 1; stiffness / damping: gentle 100/15, quick 300/20, bouncy 600/15, slow 80/20) | **fixed** | they were ad-hoc curves |
| A spring plays until it settles: its duration follows from its mass, stiffness and damping, not from the duration field | **fixed** | it was squeezed into the duration field's time; the editor shows the spring's duration in place of the field; `springs_play_until_they_settle` |
| Scrolling: horizontal, vertical or both; momentum; fixed layers stay put; sticky layers stop at the edge | pass | `scroll_offsets_fixed_and_sticky`, `present_view_tests` (sticky on the left edge too: silent) |
| Fixed layers draw and take clicks above the content scrolling under them, wherever they are in the layer list | **fixed** | they drew in layer order, under later layers; the file's order doesn't change; `fixed_layers_stay_above_the_content_that_scrolls` |
| Flows and starting points; device frames; presentation options; hotspot hints | pass | `proto_present_tests`, `present_view_tests`, `present_tests` |
## Features
| Feature | Status | Notes |
|---|---|---|
@ -19,12 +69,16 @@ action except media control, every transition and easing exist (measured from th
| Several actions per interaction; drag to reorder; conditional blocks with else-if/else | done | one undo step per edit |
| Expressions (`count + 1`, `mode(Theme) == "Dark"`, `accent[Dark]`) | done | checked when entered |
| Flows, starting points, descriptions | done | |
| Overlays: position, close on click outside, background, animate out | done | |
| Scroll behaviour: overflow directions, fixed, sticky, preserve scroll position, momentum and overscroll | done | |
| Overlays: position, close on click outside, background, animate out, stacking, timers from when they open | done | |
| Scroll behaviour: overflow directions, fixed (drawn on top), sticky, preserve scroll position, scroll to with an offset, momentum and overscroll | done | |
| Key combinations (`Shift+K`), recorded in the editor and imported from REST files | done | |
| Interactive components: instances play their variant's interactions, overridden per trigger | done | |
| Drag-driven animations that follow the pointer, in their direction | done | |
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code |
| 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 |
| 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) |
| Share a prototype link | missing | needs multiplayer/sharing |
@ -33,4 +87,5 @@ action except media control, every transition and easing exist (measured from th
| Date | Change | Summary |
|---|---|---|
| 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 |

View File

@ -51,7 +51,7 @@ blockers.
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit | 7–18 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 3–6 |
| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |

View File

@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
@ -19,9 +19,9 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Feature breadth (does each Figma Design feature exist, weighted) | **~80%** (core editor areas 1–16: ~91%) | estimated from the area table below |
| Ready for real work | **~49%** | estimated (dimension table below: written weights × written per-dimension scores) |
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
| Mainstream practitioner | **~47%** | estimated ([below](#mainstream-practitioner): weekly areas 68.8% × discounts 0.9 × 0.9 × 0.85) |
| Mainstream practitioner | **~48%** | estimated ([below](#mainstream-practitioner): weekly areas 69.1% × discounts 0.9 × 0.9 × 0.85) |
| Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~315–607 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~313–604 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places.
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~49% | ~300–576 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
| Mainstream practitioner | ~47% | ~135–251 h | core editor depth incl. the `.fig` import's tail (53–104), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Full target (ready for real work) | ~49% | ~298–573 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
| Mainstream practitioner | ~48% | ~133–248 h | core editor depth incl. the `.fig` import's tail (51–101), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@ -106,7 +106,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 |
| 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 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 |
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
| 13 | Prototyping | 6 | 90% | 74% | Video triggers and media actions, gamepad triggers, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 3–6 |
| 14 | Import/export | 6 | 80% | 50% | `.fig` import's tail (best-effort import landed: vectors as outlines, non-design layers skipped, no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 8–15 |
| 15 | Dev Mode / inspect | 2 | 90% | 64% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 |
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **157–321** |
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 52–102 |
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **155–318** |
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 50–99 |
## By dimension (ready for real work)
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth) | 35 | 57% | 54–104 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file |
| Features (depth) | 35 | 57% | 52–101 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
| **Ready for real work** | 100 | **~49%** | **~315–607** | | |
| **Ready for real work** | 100 | **~49%** | **~313–604** | | |
Dimension hours add up to the full-parity total without double counting: the feature-area rows
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc
localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration
and is left out as an area, but its effect on working with Figma users is counted in discount 3.
**2. Average depth** ("Ready" column, weighted): **68.8%**.
**2. Average depth** ("Ready" column, weighted): **69.1%**.
**3. Discounts for what still stops real work:**
@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte
| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way |
| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) |
68.8% × 0.90 × 0.90 × 0.85 = **~47%** (estimated).
69.1% × 0.90 × 0.90 × 0.85 = **~48%** (estimated).
**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments
from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is
@ -393,8 +393,13 @@ Hours in this narrative are the builders' estimates; the tables above supersede
edit is an undo step (`proto.action.*`, `proto.conditional.*`). Hotspot hints: a click where
nothing reacts flashes the screen's hotspots (an Options menu toggle, on by default); the
presenter's Inspect panel shows the hovered layer's properties. Overlays opened through commands
close on a click outside unless told not to, as in the editor. *Left:* QA against larger
real-world prototypes. 1–2 h.
close on a click outside unless told not to, as in the editor. A behaviour suite written from
the public documentation covers triggers, actions, overlays, animations, easing, scrolling and
interactive components ([prototyping-parity](prototyping-parity.md)); its fixes: drag-driven
animations go their way and follow the pointer, key combinations, overlay timers from when they
open, instances play their variant's interactions, physical springs, smart animate overshoot,
fixed layers on top; scroll to takes an offset. *Left:* QA against larger real-world
prototypes. 1–2 h.
6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import`
/ CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry
paths, auto layout, constraints, grids, components, sets, instances with overrides, component
@ -595,6 +600,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) |
| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. Paint breadth 87% → 90%, ready 66% → 67%, 4–7 h → 3–6 h; feature breadth (~80%), ready for real work (~49%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |