diff --git a/ROADMAP.md b/ROADMAP.md index 23d92a2..88a2ff1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,7 +63,9 @@ dimension measured side by side. local libraries (publish/use across files), component descriptions UI. 10–14 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. - *Left:* overlay close animation, springs, device frames, multi-selection editing. 6–10 h. + Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, + laptop, desktop bodies drawn in code) around the presented frame. *Left:* overlay close + animation, multi-selection editing, scrolling inside device frames. 5–8 h. 6. **Interop**: .fig import (best effort), PDF export (in progress), image crop handles (landed), paste from other apps, Figma REST JSON import. 25–40 h. 7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent @@ -134,7 +136,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Text | 8 | 55% | range fills in the Fill section, right-to-left/bidi, text style picker depth, axis hover preview | 10–16 | | Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 | | Styles & variables | 5 | 45% | style picker everywhere, variable binding UI, aliasing UI, scoping | 10–16 | -| Prototyping | 6 | 30% | noodle editing, overlays, scrolling, smart animate | 20–30 | +| Prototyping | 6 | 60% | overlay close animation, multi-selection editing, scrolling inside device frames | 6–10 | | Import/export | 6 | 45% | PDF (in progress), .fig import, export panel polish | 20–35 | | Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 | | Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 | diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index cec1f0a..89c72f3 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -51,7 +51,7 @@ pub enum Direction { Bottom, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum Easing { Linear, @@ -66,9 +66,28 @@ pub enum Easing { Quick, Bouncy, Slow, + /// A CSS-style cubic bezier through (0,0), (x1,y1), (x2,y2), (1,1). + #[serde(rename_all = "camelCase")] + CustomBezier { + x1: f64, + y1: f64, + x2: f64, + y2: f64, + }, + /// A damped spring (mass, stiffness, damping), played over the interaction's duration. + #[serde(rename_all = "camelCase")] + CustomSpring { + mass: f64, + stiffness: f64, + damping: f64, + }, } impl Easing { + /// The starting point when switching to a custom curve. + pub const CUSTOM_BEZIER: Easing = Easing::CustomBezier { x1: 0.42, y1: 0.0, x2: 0.58, y2: 1.0 }; + pub const CUSTOM_SPRING: Easing = Easing::CustomSpring { mass: 1.0, stiffness: 100.0, damping: 15.0 }; + pub const ALL: [Easing; 11] = [ Easing::Linear, Easing::EaseIn, @@ -96,12 +115,14 @@ impl Easing { Easing::Quick => "Quick", Easing::Bouncy => "Bouncy", Easing::Slow => "Slow", + Easing::CustomBezier { .. } => "Custom bezier", + Easing::CustomSpring { .. } => "Custom spring", } } /// Spring presets run longer than the chosen duration in the incumbent; we keep the duration. pub fn is_spring(self) -> bool { - matches!(self, Easing::Gentle | Easing::Quick | Easing::Bouncy | Easing::Slow) + matches!(self, Easing::Gentle | Easing::Quick | Easing::Bouncy | Easing::Slow | Easing::CustomSpring { .. }) } /// Evaluate the curve at `t` (0..1). @@ -134,10 +155,61 @@ impl Easing { 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::CustomBezier { x1, y1, x2, y2 } => cubic_bezier(t, x1, y1, x2, y2), + Easing::CustomSpring { mass, stiffness, damping } => physical_spring(t, mass, stiffness, damping), } } } +/// CSS `cubic-bezier(x1, y1, x2, y2)` at time `t` (x clamped to 0..1 so time stays monotonic). +fn cubic_bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { + let fin = |v: f64, d: f64| if v.is_finite() { v } else { d }; + let (x1, x2) = (fin(x1, 0.42).clamp(0.0, 1.0), fin(x2, 0.58).clamp(0.0, 1.0)); + let (y1, y2) = (fin(y1, 0.0).clamp(-10.0, 10.0), fin(y2, 1.0).clamp(-10.0, 10.0)); + let b = |p1: f64, p2: f64, s: f64| { + let u = 1.0 - s; + 3.0 * u * u * s * p1 + 3.0 * u * s * s * p2 + s * s * s + }; + // x(s) is monotonic for x1, x2 in 0..1: bisect for s with x(s) = t. + let (mut lo, mut hi) = (0.0, 1.0); + for _ in 0..48 { + let mid = (lo + hi) / 2.0; + if b(x1, x2, mid) < t { + lo = mid; + } else { + hi = mid; + } + } + 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 } +} + fn spring(t: f64, freq: f64, damping: f64) -> f64 { if t >= 1.0 { return 1.0; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index bf32fe8..d0562d8 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -293,3 +293,40 @@ fn next_name_increments() { assert_eq!(d.next_name("Frame"), "Frame 8"); assert_eq!(d.next_name("Ellipse"), "Ellipse 1"); } + +#[test] +fn custom_easings() { + use crate::proto::Easing; + // cubic-bezier(0.25, 0.1, 0.25, 1) is CSS `ease`: 0.8024 at half time. + let ease = Easing::CustomBezier { x1: 0.25, y1: 0.1, x2: 0.25, y2: 1.0 }; + assert!((ease.eval(0.5) - 0.8024).abs() < 1e-3, "{}", ease.eval(0.5)); + let linear = Easing::CustomBezier { x1: 0.0, y1: 0.0, x2: 1.0, y2: 1.0 }; + for t in [0.0, 0.1, 0.37, 0.9, 1.0] { + assert!((linear.eval(t) - t).abs() < 1e-6); + } + // Springs start at 0, end at 1; an under-damped one overshoots. + let bouncy = Easing::CustomSpring { mass: 1.0, stiffness: 300.0, damping: 6.0 }; + assert!(bouncy.eval(0.0).abs() < 1e-9); + assert_eq!(bouncy.eval(1.0), 1.0); + assert!((0..100).map(|i| bouncy.eval(f64::from(i) / 100.0)).any(|v| v > 1.01), "overshoots"); + let stiff = Easing::CustomSpring { mass: 1.0, stiffness: 100.0, damping: 40.0 }; + assert!((0..100).map(|i| stiff.eval(f64::from(i) / 100.0)).all(|v| (-1e-9..=1.0 + 1e-9).contains(&v)), "over-damped never overshoots"); + assert!(Easing::CUSTOM_SPRING.is_spring()); + // Junk values stay finite. + for e in [ + Easing::CustomBezier { x1: f64::NAN, y1: f64::INFINITY, x2: -5.0, y2: 1e300 }, + Easing::CustomSpring { mass: 0.0, stiffness: -1.0, damping: f64::NAN }, + Easing::CustomSpring { mass: 1e300, stiffness: 1e-300, damping: 0.0 }, + ] { + for i in 0..=20 { + assert!(e.eval(f64::from(i) / 20.0).is_finite()); + } + } + // Serde: presets stay strings, custom curves are objects. + assert_eq!(serde_json::to_value(Easing::Gentle).unwrap(), serde_json::json!("gentle")); + let v = serde_json::to_value(Easing::CUSTOM_BEZIER).unwrap(); + assert_eq!(v["customBezier"]["x1"], serde_json::json!(0.42)); + let back: Easing = serde_json::from_value(serde_json::json!({"customSpring": {"mass": 2, "stiffness": 50, "damping": 5}})).unwrap(); + assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 }); + assert_eq!(serde_json::from_value::(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut); +} diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index e2696d4..59a138f 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { spec!( "proto.interaction.add", "Add interaction", - "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate|back|scrollTo|openOverlay {position?, close_on_outside?, background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value}, 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} — or {id, dest} for a click-navigate. Returns {index}", + "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate|back|scrollTo|openOverlay {position?, close_on_outside?, background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value}, 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 ), diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 56aaa43..b3b92f1 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -782,10 +782,23 @@ struct Layout { overlays: Vec<(NodeId, Place, Rect)>, } +/// The page's prototype device ("None" when unset). +fn device_of(p: &Presenter) -> String { + p.doc + .as_ref() + .and_then(|d| d.page_of(p.current).and_then(|pg| d.node(pg))) + .and_then(|n| match &n.kind { + NodeKind::Page { prototype, .. } => Some(prototype.device.clone()), + _ => None, + }) + .unwrap_or_else(|| "None".into()) +} + fn layout(p: &mut Presenter, area: Rect) -> Option { let disp = p.display()?; let fb = disp.world_bounds(p.current)?; - let avail = area.shrink(32.0); + // Leave room for the device body around the screen. + let avail = if device_of(p) == "None" { area.shrink(32.0) } else { area.shrink2(vec2(72.0, 80.0)) }; let scale = (f64::from(avail.width()) / fb.width().max(1.0)).min(f64::from(avail.height()) / fb.height().max(1.0)).clamp(0.01, 1.0); let place = Place::centered(fb, area.center(), scale); let target = place.rect(fb); @@ -831,6 +844,79 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.present = Some(pres); } +/// Body of the presentation device behind the screen `s` (drawn in code: rounded slabs). +fn device_body(ui: &Ui, device: &str, s: Rect) { + let p = ui.painter(); + let body = Color32::from_rgb(0x1C, 0x1C, 0x1E); + let edge = Color32::from_rgb(0x5A, 0x5A, 0x5E); + let w = s.width(); + match device { + "None" => {} + "MacBook Air" => { + let b = (w * 0.025).max(6.0); + let lid = s.expand(b); + p.rect_filled(lid, CornerRadius::same((b * 1.2).min(255.0) as u8), body); + p.rect_stroke(lid, CornerRadius::same((b * 1.2).min(255.0) as u8), egui::Stroke::new(1.0, edge), egui::StrokeKind::Outside); + // The base: a thin slab wider than the lid, with a finger notch. + let base = Rect::from_min_max(pos2(lid.min.x - w * 0.08, lid.max.y), pos2(lid.max.x + w * 0.08, lid.max.y + (w * 0.022).max(6.0))); + p.rect_filled(base, CornerRadius { nw: 2, ne: 2, sw: 10, se: 10 }, Color32::from_rgb(0xB8, 0xB9, 0xBD)); + let notch = Rect::from_center_size(pos2(base.center().x, base.min.y + 1.5), vec2(w * 0.12, 3.0)); + p.rect_filled(notch, CornerRadius::same(2), Color32::from_rgb(0x8E, 0x8E, 0x93)); + } + "Desktop" => { + let b = (w * 0.02).max(6.0); + let screen = s.expand(b); + p.rect_filled(screen, CornerRadius::same(8), body); + p.rect_stroke(screen, CornerRadius::same(8), egui::Stroke::new(1.0, edge), egui::StrokeKind::Outside); + let neck = + Rect::from_min_max(pos2(screen.center().x - w * 0.06, screen.max.y), pos2(screen.center().x + w * 0.06, screen.max.y + w * 0.08)); + p.rect_filled(neck, CornerRadius::ZERO, Color32::from_rgb(0xB8, 0xB9, 0xBD)); + let foot = Rect::from_min_max(pos2(neck.min.x - w * 0.06, neck.max.y), pos2(neck.max.x + w * 0.06, neck.max.y + 6.0)); + p.rect_filled(foot, CornerRadius::same(3), Color32::from_rgb(0xA0, 0xA1, 0xA6)); + } + _ => { + // Phones and tablets: a rounded slab with side buttons. + let tablet = device.starts_with("iPad"); + let b = if tablet { (w * 0.05).max(8.0) } else { (w * 0.06).max(6.0) }; + let screen_r = if tablet { w * 0.04 } else { w * 0.13 }; + let slab = s.expand(b); + let r = CornerRadius::same((screen_r + b).min(255.0) as u8); + for (x, y0, h) in [(slab.min.x - 2.0, 0.18, 0.06), (slab.min.x - 2.0, 0.27, 0.1), (slab.max.x, 0.24, 0.13)] { + if tablet { + break; + } + let btn = Rect::from_min_size(pos2(x, slab.min.y + slab.height() * y0), vec2(2.5, slab.height() * h)); + p.rect_filled(btn, CornerRadius::same(1), edge); + } + p.rect_filled(slab, r, body); + p.rect_stroke(slab, r, egui::Stroke::new(1.5, edge), egui::StrokeKind::Inside); + } + } +} + +/// What sits on top of the screen: rounded screen corners (the body colour masks the frame's +/// square corners) and a phone's camera pill. +fn device_glass(ui: &Ui, device: &str, s: Rect) { + let p = ui.painter(); + let body = Color32::from_rgb(0x1C, 0x1C, 0x1E); + let w = s.width(); + match device { + "None" | "Desktop" | "MacBook Air" => {} + _ => { + let tablet = device.starts_with("iPad"); + let screen_r = if tablet { w * 0.04 } else { w * 0.13 }; + // An outside stroke along the rounded screen covers the frame's square corners. + let bezel = if tablet { (w * 0.05).max(8.0) } else { (w * 0.06).max(6.0) }; + let cover = (screen_r * 0.45 + 1.0).min(bezel); + p.rect_stroke(s, CornerRadius::same(screen_r.min(255.0) as u8), egui::Stroke::new(cover, body), egui::StrokeKind::Outside); + if device.starts_with("iPhone") { + let pill = Rect::from_center_size(pos2(s.center().x, s.min.y + w * 0.045), vec2(w * 0.3, w * 0.085)); + p.rect_filled(pill, CornerRadius::same((w * 0.0425).min(255.0) as u8), Color32::BLACK); + } + } + } +} + /// One frame of the player; returns true to close it. fn player(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, now: f64) -> bool { let rect = ui.max_rect(); @@ -856,7 +942,10 @@ fn player(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, now: f64) -> bool input(p, ctx, &l, resp.contains_pointer(), now); } let Some(l) = layout(p, area) else { return true }; + let device = device_of(p); + device_body(ui, &device, l.target); draw(p, ui, ctx, &l, area, now); + device_glass(ui, &device, l.target); // Timers: the soonest pending "after delay". let mut next: Option = None; for id in l.disp.subtree(p.current) { diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 47f251a..9d37217 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -717,8 +717,9 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, it.easing.label(), ui.available_width() - 80.0, None); egui::Popup::menu(&resp).width(220.0).show(|ui| { - for (i, e) in Easing::ALL.iter().enumerate() { - if i == 7 { + let custom = [Easing::CUSTOM_BEZIER, Easing::CUSTOM_SPRING]; + for (i, e) in Easing::ALL.iter().chain(custom.iter()).enumerate() { + if i == 7 || i == Easing::ALL.len() { ui.separator(); } let w = ui.available_width().max(200.0); @@ -730,7 +731,9 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In curve(ui.painter(), Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(28.0, 18.0)), *e, col, None); ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, e.label(), regular(11.5), col); if rr.clicked() { - patch(app, node, k, json!({"easing": e})); + // Keep the current custom values when re-picking the same custom kind. + let same_kind = std::mem::discriminant(e) == std::mem::discriminant(&it.easing); + patch(app, node, k, json!({"easing": if same_kind { it.easing } else { *e }})); ui.close(); } } @@ -755,6 +758,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In } } }); + custom_easing_fields(app, ui, node, k, it.easing); // Curve preview, with a dot running along it. let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); @@ -767,6 +771,61 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In } } +/// Fields for a custom bezier (x1, y1, x2, y2) or spring (mass, stiffness, damping). +fn custom_easing_fields(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, e: Easing) { + let (names, vals, steps): ([&str; 4], [f64; 4], [f64; 4]) = match e { + Easing::CustomBezier { x1, y1, x2, y2 } => (["X1", "Y1", "X2", "Y2"], [x1, y1, x2, y2], [0.01; 4]), + Easing::CustomSpring { mass, stiffness, damping } => { + (["Mass", "Stiffness", "Damping", ""], [mass, stiffness, damping, 0.0], [0.1, 1.0, 0.5, 0.0]) + } + _ => return, + }; + let count = if matches!(e, Easing::CustomBezier { .. }) { 4 } else { 3 }; + let make = |vals: [f64; 4]| match e { + Easing::CustomBezier { .. } => Easing::CustomBezier { + x1: vals[0].clamp(0.0, 1.0), + y1: vals[1].clamp(-5.0, 5.0), + x2: vals[2].clamp(0.0, 1.0), + y2: vals[3].clamp(-5.0, 5.0), + }, + _ => Easing::CustomSpring { mass: vals[0].clamp(0.01, 100.0), stiffness: vals[1].clamp(1.0, 10_000.0), damping: vals[2].clamp(0.0, 1000.0) }, + }; + row(ui, 32.0, |ui| { + let w = ((ui.available_width() - 6.0 * (count as f32 - 1.0)) / count as f32).max(36.0); + for i in 0..count { + let (Some(name), Some(v), Some(step)) = (names.get(i), vals.get(i), steps.get(i)) else { continue }; + let lead = if count == 4 { Lead::Text(name) } else { Lead::None }; + let edit = widgets::number_field(ui, Id::new(("proto_ease", node, k, i)), lead, Some((v * 100.0).round() / 100.0), "", w, *step).map( + |e| match e { + FieldEdit::Set(x) => (x, true, false), + FieldEdit::Scrub(x, done) => (x, done, true), + }, + ); + if let Some((x, done, scrub)) = edit + && x.is_finite() + { + let mut nv = vals; + if let Some(slot) = nv.get_mut(i) { + *slot = x; + } + let eased = make(nv); + if scrub { + let _ = app.session.begin_interaction("Edit"); + let _ = app.session.execute("proto.interaction.set", &json!({"ids": [node], "index": k, "easing": eased})); + if done { + let _ = app.session.commit_interaction(); + } + } else { + patch(app, node, k, json!({"easing": eased})); + } + } + } + }); + if count == 3 { + widgets::sublabels(ui, "Mass · Stiffness · Damping", None); + } +} + /// Draw an easing curve in `r` (x = time, y = progress, up). `dot` marks a point in time. pub fn curve(p: &egui::Painter, r: Rect, e: Easing, col: Color32, dot: Option) { let n = 48;