M5: prototyping: custom bezier and spring easings, device frames in the presenter (#10)
- Easing gains `customBezier {x1, y1, x2, y2}` (CSS cubic-bezier) and `customSpring {mass,
stiffness, damping}` (a damped spring's step response over its settling time, played over the
interaction's duration); presets keep their string form, so files stay compatible.
- Interaction editor: Custom bezier / Custom spring in the easing menu with value fields (typed or
scrubbed, one undo step), the curve preview follows.
- Presenter: the page's prototype device now frames the presented screen with a body drawn in
code (phones with rounded screens, side buttons and a camera pill; tablets; a laptop with its
base; a desktop monitor on a stand).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ef97225e07
commit
c6df8302b1
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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|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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<Layout> {
|
||||
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<f64> = None;
|
||||
for id in l.disp.subtree(p.current) {
|
||||
|
|
|
|||
|
|
@ -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<f64>) {
|
||||
let n = 48;
|
||||
|
|
|
|||
Loading…
Reference in New Issue