diff --git a/examples/marks.tsx b/examples/marks.tsx new file mode 100644 index 00000000..d9341752 --- /dev/null +++ b/examples/marks.tsx @@ -0,0 +1,58 @@ +import { createRoot, CanvasPathCommand } from "@gpuix/react" +import { OrganicBody, VectorEyes, DecorativeStroke, VectorPath } from "@gpuix/react/src/components/marks.js" +import { GpuixRenderer } from "@gpuix/native" + +const root = createRoot(new GpuixRenderer()) + +const bodyPath: CanvasPathCommand[] = [ + { type: "move", x: 100, y: 50 }, + { type: "curve", x1: 150, y1: 50, x2: 150, y2: 150, x: 100, y: 150 }, + { type: "curve", x1: 50, y1: 150, x2: 50, y2: 50, x: 100, y: 50 }, + { type: "close" } +] + +const eyePath: CanvasPathCommand[] = [ + { type: "move", x: 80, y: 90 }, + { type: "line", x: 90, y: 90 }, + { type: "move", x: 110, y: 90 }, + { type: "line", x: 120, y: 90 } +] + +const accentPath: CanvasPathCommand[] = [ + { type: "move", x: 60, y: 60 }, + { type: "curve", x1: 70, y1: 40, x2: 130, y2: 40, x: 140, y: 60 } +] + +root.render( +
+ Living Vector Marks + +
+ +
+ +
+
+ +
+
+
+) diff --git a/packages/native/src/custom_elements/canvas.rs b/packages/native/src/custom_elements/canvas.rs new file mode 100644 index 00000000..b00c359f --- /dev/null +++ b/packages/native/src/custom_elements/canvas.rs @@ -0,0 +1,228 @@ +use super::{CustomElement, CustomElementFactory, CustomRenderContext}; +use serde::Deserialize; +use web_time::Instant; + +pub struct CanvasFactory; + +impl CustomElementFactory for CanvasFactory { + fn element_type(&self) -> &str { + "canvas" + } + + fn create(&self, _id: u64) -> Box { + Box::new(CanvasElement { + shapes: Vec::new(), + start_time: Instant::now(), + }) + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PathCommand { + #[serde(rename = "type")] + pub cmd_type: String, + pub x: Option, + pub y: Option, + pub x1: Option, + pub y1: Option, + pub x2: Option, + pub y2: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CanvasShape { + pub shape_type: Option, + pub path: Option>, + pub fill: Option, + pub stroke: Option, + pub stroke_width: Option, + pub squash: Option, + pub breathe_loop: Option, + pub glance_x: Option, + pub glance_y: Option, + pub blink: Option, + pub blink_loop: Option, + pub wiggle: Option, + pub wiggle_loop: Option, +} + +#[derive(Debug, Clone)] +pub struct CanvasElement { + shapes: Vec, + start_time: Instant, +} + +impl CustomElement for CanvasElement { + fn render( + &mut self, + ctx: CustomRenderContext, + window: &mut gpui::Window, + _cx: &mut gpui::Context, + ) -> gpui::AnyElement { + use gpui::prelude::*; + + let shapes = self.shapes.clone(); + let mut has_animation = false; + for shape in &shapes { + if shape.breathe_loop.is_some() || shape.blink_loop.is_some() || shape.wiggle_loop.is_some() { + has_animation = true; + } + } + + if has_animation { + window.request_animation_frame(); + } + + let element_id = gpui::SharedString::from(format!("__gpuix_canvas_{}", ctx.id)); + let start_time = self.start_time; + + let el = gpui::canvas(move |bounds, window| { + let now = window.cx().now(); + let elapsed = now.duration_since(start_time).as_secs_f64(); + + for shape in &shapes { + let Some(cmds) = &shape.path else { continue }; + + let mut fill = None; + if let Some(f) = &shape.fill { + if let Some(c) = crate::color::parse_color_rgba(f) { + fill = Some(c); + } + } + + let mut stroke = None; + if let Some(s) = &shape.stroke { + if let Some(c) = crate::color::parse_color_rgba(s) { + stroke = Some(c); + } + } + + let stroke_width = shape.stroke_width.unwrap_or(1.0); + + let mut bounds_min_x = f32::MAX; + let mut bounds_max_x = f32::MIN; + let mut bounds_min_y = f32::MAX; + let mut bounds_max_y = f32::MIN; + + for cmd in cmds { + if let Some(x) = cmd.x { + bounds_min_x = bounds_min_x.min(x); + bounds_max_x = bounds_max_x.max(x); + } + if let Some(y) = cmd.y { + bounds_min_y = bounds_min_y.min(y); + bounds_max_y = bounds_max_y.max(y); + } + } + + let cx = (bounds_min_x + bounds_max_x) / 2.0; + let cy = (bounds_min_y + bounds_max_y) / 2.0; + + let mut scale_x = 1.0; + let mut scale_y = 1.0; + let mut trans_x = bounds.origin.x.0; + let mut trans_y = bounds.origin.y.0; + + if let Some(squash) = shape.squash { + let mut s = squash; + if let Some(speed) = shape.breathe_loop { + s *= (elapsed * speed as f64).sin() as f32; + } + scale_x += s; + scale_y -= s; + } + + if let Some(blink) = shape.blink { + let mut b = blink; + if let Some(speed) = shape.blink_loop { + b *= ((elapsed * speed as f64).sin() as f32).max(0.0); + } + scale_y *= (1.0 - b).max(0.1); + } + + if let Some(glance_x) = shape.glance_x { + trans_x += glance_x; + } + if let Some(glance_y) = shape.glance_y { + trans_y += glance_y; + } + + let wiggle_amp = shape.wiggle.unwrap_or(0.0); + let wiggle_speed = shape.wiggle_loop.unwrap_or(0.0) as f64; + + let tx = |x: f32, y: f32| -> gpui::Point { + let mut nx = (x - cx) * scale_x + cx; + let mut ny = (y - cy) * scale_y + cy; + if wiggle_amp > 0.0 && wiggle_speed > 0.0 { + nx += ((elapsed * wiggle_speed + y as f64 * 0.1).sin() as f32) * wiggle_amp; + ny += ((elapsed * wiggle_speed + x as f64 * 0.1).cos() as f32) * wiggle_amp; + } + gpui::point(gpui::px(nx + trans_x), gpui::px(ny + trans_y)) + }; + + let mut draw_path = |is_stroke: bool| { + let mut builder = if is_stroke { + gpui::PathBuilder::stroke(gpui::px(stroke_width)) + } else { + gpui::PathBuilder::fill() + }; + + for cmd in cmds { + match cmd.cmd_type.as_str() { + "move" => builder.move_to(tx(cmd.x.unwrap_or(0.0), cmd.y.unwrap_or(0.0))), + "line" => builder.line_to(tx(cmd.x.unwrap_or(0.0), cmd.y.unwrap_or(0.0))), + "curve" => { + let ctrl1 = tx(cmd.x1.unwrap_or(0.0), cmd.y1.unwrap_or(0.0)); + let ctrl2 = tx(cmd.x2.unwrap_or(0.0), cmd.y2.unwrap_or(0.0)); + let to = tx(cmd.x.unwrap_or(0.0), cmd.y.unwrap_or(0.0)); + builder.cubic_bezier_to(to, ctrl1, ctrl2); + } + "close" => builder.close(), + _ => {} + } + } + + builder.build() + }; + + if let Some(f) = fill { + if let Ok(path) = draw_path(false) { + window.paint_path(path, f); + } + } + if let Some(s) = stroke { + if let Ok(path) = draw_path(true) { + window.paint_path(path, s); + } + } + } + }).size_full(); + + let mut div = gpui::div().id(element_id).size_full().child(el); + if let Some(style) = ctx.style { + div = crate::renderer::apply_interactive_styles(div, style); + } + let div = super::wire_standard_events(div, &ctx); + crate::automation::track_own_bounds(div, ctx.id).into_any_element() + } + + fn set_prop(&mut self, key: &str, value: serde_json::Value) { + if key == "shapes" { + if let Ok(shapes) = serde_json::from_value::>(value) { + self.shapes = shapes; + } + } + } + + fn supported_props(&self) -> &'static [&'static str] { + &["shapes"] + } + + fn supported_events(&self) -> &'static [&'static str] { + &["click", "mouseEnter", "mouseLeave"] + } + + fn destroy(&mut self) {} +} diff --git a/packages/native/src/custom_elements/mod.rs b/packages/native/src/custom_elements/mod.rs index b3811c10..f51a5548 100644 --- a/packages/native/src/custom_elements/mod.rs +++ b/packages/native/src/custom_elements/mod.rs @@ -19,6 +19,7 @@ pub mod diff; pub mod img; pub mod input; pub mod markdown; +pub mod canvas; // ── Render context ─────────────────────────────────────────────────── @@ -285,6 +286,7 @@ impl CustomElementRegistry { registry.register(Box::new(code::CodeFactory)); registry.register(Box::new(diff::DiffFactory)); registry.register(Box::new(markdown::MarkdownFactory)); + registry.register(Box::new(canvas::CanvasFactory)); registry } diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index eb719b80..ec93b2bb 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -1,4 +1,5 @@ //! Native motion tracks resolved during GPUI rendering, outside React. +//! Tween (duration/ease) plus spring (stiffness/damping/mass/velocity) integrators. use std::time::Duration; @@ -38,6 +39,33 @@ impl MotionStyle { } } + fn channels(self) -> [( &'static str, Option); 8] { + [ + ("width", self.width), + ("height", self.height), + ("opacity", self.opacity), + ("top", self.top), + ("right", self.right), + ("bottom", self.bottom), + ("left", self.left), + ("borderRadius", self.border_radius), + ] + } + + fn set(&mut self, name: &str, value: f64) { + match name { + "width" => self.width = Some(value), + "height" => self.height = Some(value), + "opacity" => self.opacity = Some(value), + "top" => self.top = Some(value), + "right" => self.right = Some(value), + "bottom" => self.bottom = Some(value), + "left" => self.left = Some(value), + "borderRadius" => self.border_radius = Some(value), + _ => {} + } + } + pub(crate) fn apply_to(self, style: &mut StyleDesc) { if let Some(value) = self.width { style.width = Some(DimensionValue::Pixels(value)); @@ -82,7 +110,7 @@ enum MotionEase { #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -struct MotionTransition { +struct TweenTransition { #[serde(default = "default_duration")] duration: f64, #[serde(default)] @@ -91,13 +119,37 @@ struct MotionTransition { ease: MotionEase, } +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct SpringTransition { + #[serde(rename = "type")] + kind: String, + #[serde(default = "default_stiffness")] + stiffness: f64, + #[serde(default = "default_damping")] + damping: f64, + #[serde(default = "default_mass")] + mass: f64, + #[serde(default)] + velocity: f64, + #[serde(default)] + delay: f64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum MotionTransition { + Spring(SpringTransition), + Tween(TweenTransition), +} + impl Default for MotionTransition { fn default() -> Self { - Self { + Self::Tween(TweenTransition { duration: default_duration(), delay: 0.0, ease: default_ease(), - } + }) } } @@ -109,6 +161,18 @@ fn default_ease() -> MotionEase { MotionEase::Name("easeOut".to_string()) } +fn default_stiffness() -> f64 { + 36.0 +} + +fn default_damping() -> f64 { + 8.0 +} + +fn default_mass() -> f64 { + 1.2 +} + #[derive(Clone, Debug, Deserialize, PartialEq)] struct MotionDescription { #[serde(default)] @@ -124,15 +188,35 @@ pub(crate) struct MotionFrame { pub active: bool, } +#[derive(Clone, Copy, Debug, Default)] +struct SpringTrack { + pos: f64, + vel: f64, +} + pub(crate) struct MotionState { source: serde_json::Value, from: MotionStyle, target: MotionStyle, + current: MotionStyle, transition: MotionTransition, started: Instant, + last: Instant, + springs: [SpringTrack; 8], valid: bool, } +const CHANNELS: [&str; 8] = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +]; + impl MotionState { pub(crate) fn new(source: &serde_json::Value, now: Instant) -> Result { let description = parse_description(source)?; @@ -141,13 +225,16 @@ impl MotionState { Some(MotionInitial::Disabled(false)) | None => description.animate, Some(MotionInitial::Disabled(true)) => unreachable!("validated above"), }; - + let kick = spring_kick(&description.transition); Ok(Self { source: source.clone(), from, target: description.animate, + current: from, transition: description.transition, started: now, + last: now, + springs: seed_springs(from, description.animate, kick), valid: true, }) } @@ -157,8 +244,11 @@ impl MotionState { source: source.clone(), from: MotionStyle::default(), target: MotionStyle::default(), + current: MotionStyle::default(), transition: MotionTransition::default(), started: now, + last: now, + springs: [SpringTrack::default(); 8], valid: false, } } @@ -180,7 +270,7 @@ impl MotionState { return Err(error); } }; - self.from = if self.valid { + let visible = if self.valid { self.frame(now).style } else { match description.initial { @@ -189,17 +279,40 @@ impl MotionState { Some(MotionInitial::Disabled(true)) => unreachable!("validated above"), } }; + self.from = visible; + self.current = visible; self.target = description.animate; + let kick = spring_kick(&description.transition); + if matches!(description.transition, MotionTransition::Spring(_)) { + // Keep velocity; retarget in place so overshoot carries. + for (index, name) in CHANNELS.iter().enumerate() { + let pos = channel(visible, name).unwrap_or_else(|| channel(description.animate, name).unwrap_or(0.0)); + self.springs[index].pos = pos; + if self.springs[index].vel.abs() < f64::EPSILON { + self.springs[index].vel = kick; + } + } + } else { + self.springs = seed_springs(visible, description.animate, kick); + } self.transition = description.transition; self.started = now; + self.last = now; self.source = source.clone(); self.valid = true; Ok(()) } - pub(crate) fn frame(&self, now: Instant) -> MotionFrame { - let delay = seconds(self.transition.delay); - let duration = seconds(self.transition.duration); + pub(crate) fn frame(&mut self, now: Instant) -> MotionFrame { + match &self.transition { + MotionTransition::Spring(spring) => self.spring_frame(now, spring.clone()), + MotionTransition::Tween(tween) => self.tween_frame(now, tween.clone()), + } + } + + fn tween_frame(&self, now: Instant, tween: TweenTransition) -> MotionFrame { + let delay = seconds(tween.delay); + let duration = seconds(tween.duration); let elapsed = now.saturating_duration_since(self.started); let raw = if elapsed <= delay { 0.0 @@ -209,13 +322,103 @@ impl MotionState { elapsed.saturating_sub(delay).as_secs_f64() / duration.as_secs_f64() }; let active = self.from != self.target && raw < 1.0; - let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease); - + let progress = ease(raw.clamp(0.0, 1.0), &tween.ease); MotionFrame { style: self.from.interpolate(self.target, progress), active, } } + + fn spring_frame(&mut self, now: Instant, spring: SpringTransition) -> MotionFrame { + let delay = seconds(spring.delay); + if now.saturating_duration_since(self.started) < delay { + self.last = now; + return MotionFrame { + style: self.current, + active: self.from != self.target, + }; + } + let mut dt = now.saturating_duration_since(self.last).as_secs_f64(); + self.last = now; + if dt <= 0.0 { + return MotionFrame { + style: self.current, + active: !settled(&self.springs, self.target), + }; + } + dt = dt.min(0.032); + let mut style = self.current; + let mut active = false; + for (index, name) in CHANNELS.iter().enumerate() { + let Some(target) = channel(self.target, name) else { + continue; + }; + let rest = if *name == "opacity" { 0.002 } else { 0.05 }; + let next = step_spring(self.springs[index], target, dt, spring.stiffness, spring.damping, spring.mass, rest); + self.springs[index] = next; + style.set(name, next.pos); + if (next.pos - target).abs() > rest || next.vel.abs() > rest { + active = true; + } + } + self.current = style; + MotionFrame { style, active } + } +} + +fn channel(style: MotionStyle, name: &str) -> Option { + match name { + "width" => style.width, + "height" => style.height, + "opacity" => style.opacity, + "top" => style.top, + "right" => style.right, + "bottom" => style.bottom, + "left" => style.left, + "borderRadius" => style.border_radius, + _ => None, + } +} + +fn spring_kick(transition: &MotionTransition) -> f64 { + match transition { + MotionTransition::Spring(spring) => spring.velocity, + MotionTransition::Tween(_) => 0.0, + } +} + +fn seed_springs(from: MotionStyle, target: MotionStyle, kick: f64) -> [SpringTrack; 8] { + let mut tracks = [SpringTrack::default(); 8]; + for (index, name) in CHANNELS.iter().enumerate() { + let pos = channel(from, name).or_else(|| channel(target, name)).unwrap_or(0.0); + tracks[index] = SpringTrack { pos, vel: kick }; + } + tracks +} + +fn settled(tracks: &[SpringTrack; 8], target: MotionStyle) -> bool { + for (index, name) in CHANNELS.iter().enumerate() { + let Some(to) = channel(target, name) else { + continue; + }; + if (tracks[index].pos - to).abs() > 0.05 || tracks[index].vel.abs() > 0.05 { + return false; + } + } + true +} + +fn step_spring(track: SpringTrack, target: f64, dt: f64, stiffness: f64, damping: f64, mass: f64, rest: f64) -> SpringTrack { + let mass = mass.max(0.001); + let x = track.pos - target; + let accel = (-stiffness * x - damping * track.vel) / mass; + let vel = track.vel + accel * dt; + let pos = track.pos + vel * dt; + if (pos - target).abs() < rest && vel.abs() < rest { + SpringTrack { pos: target, vel: 0.0 } + } else { + SpringTrack { pos, vel } + } } fn parse_description(source: &serde_json::Value) -> Result { @@ -229,23 +432,30 @@ fn parse_description(source: &serde_json::Value) -> Result { + validate_seconds(tween.duration, "duration")?; + validate_seconds(tween.delay, "delay")?; + validate_ease(&tween.ease)?; + } + MotionTransition::Spring(spring) => { + if spring.kind != "spring" { + return Err(format!("unknown motion type: {}", spring.kind)); + } + validate_positive(spring.stiffness, "stiffness")?; + validate_positive(spring.damping, "damping")?; + validate_positive(spring.mass, "mass")?; + validate_seconds(spring.delay, "delay")?; + if !spring.velocity.is_finite() { + return Err("motion velocity must be finite".to_string()); + } + } + } Ok(description) } fn validate_style(style: &MotionStyle) -> Result<(), String> { - for (name, value) in [ - ("width", style.width), - ("height", style.height), - ("opacity", style.opacity), - ("top", style.top), - ("right", style.right), - ("bottom", style.bottom), - ("left", style.left), - ("borderRadius", style.border_radius), - ] { + for (name, value) in style.channels() { if value.is_some_and(|value| !value.is_finite() || value.abs() > f32::MAX as f64) { return Err(format!("motion {name} must fit a finite 32-bit float")); } @@ -274,6 +484,13 @@ fn validate_seconds(value: f64, name: &str) -> Result<(), String> { Ok(()) } +fn validate_positive(value: f64, name: &str) -> Result<(), String> { + if !value.is_finite() || value <= 0.0 { + return Err(format!("motion {name} must be a finite number greater than 0")); + } + Ok(()) +} + fn validate_ease(ease: &MotionEase) -> Result<(), String> { match ease { MotionEase::Name(name) @@ -406,10 +623,32 @@ mod tests { "animate": { "width": 100.0 }, "transition": { "duration": 0.2, "ease": "linear" } }); - let state = MotionState::new(&description, started).unwrap(); + let mut state = MotionState::new(&description, started).unwrap(); let frame = state.frame(started + Duration::from_millis(200)); assert_eq!(frame.style.width, Some(100.0)); assert!(!frame.active); } + + #[test] + fn spring_overshoots_then_settles() { + let started = Instant::now(); + let description = serde_json::json!({ + "initial": { "width": 0.0 }, + "animate": { "width": 100.0 }, + "transition": { "type": "spring", "stiffness": 40.0, "damping": 6.0, "mass": 1.0 } + }); + let mut state = MotionState::new(&description, started).unwrap(); + let mut max_width = 0.0; + let mut now = started; + for _ in 0..120 { + now += Duration::from_millis(8); + let frame = state.frame(now); + max_width = max_width.max(frame.style.width.unwrap_or(0.0)); + } + assert!(max_width > 100.0, "gelatinous spring must overshoot, got {max_width}"); + let settled = state.frame(now); + assert!((settled.style.width.unwrap_or(0.0) - 100.0).abs() < 1.0); + assert!(!settled.active); + } } diff --git a/packages/react/src/__tests__/marks.test.tsx b/packages/react/src/__tests__/marks.test.tsx new file mode 100644 index 00000000..f5d64630 --- /dev/null +++ b/packages/react/src/__tests__/marks.test.tsx @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, test } from "vitest" +import { createTestRoot } from "../testing.js" +import { OrganicBody, VectorEyes, DecorativeStroke, VectorPath, Canvas } from "../components/marks.js" +import { CanvasPathCommand } from "../components/marks.js" + +describe("Living Vector Marks", () => { + const root = createTestRoot() + afterEach(() => root.unmount()) + + test("renders VectorPath and OrganicBody to the native tree", () => { + const path: CanvasPathCommand[] = [ + { type: "move", x: 0, y: 0 }, + { type: "line", x: 10, y: 10 } + ] + + root.render( +
+ + +
+ ) + + const canvasElements = root.findType("canvas") + expect(canvasElements.length).toBe(2) + + // First canvas + const canvas1 = canvasElements[0] + expect(canvas1.props.shapes).toBeDefined() + expect(canvas1.props.shapes[0].type).toBe("path") + expect(canvas1.props.shapes[0].fill).toBe("#ff0000") + + // Second canvas + const canvas2 = canvasElements[1] + expect(canvas2.props.shapes[0].type).toBe("body") + expect(canvas2.props.shapes[0].squash).toBe(0.5) + expect(canvas2.props.shapes[0].breatheLoop).toBe(1.0) + }) + + test("renders Eyes and Stroke marks", () => { + const path: CanvasPathCommand[] = [{ type: "move", x: 0, y: 0 }] + + root.render( +
+ + +
+ ) + + const canvasElements = root.findType("canvas") + expect(canvasElements.length).toBe(2) + + expect(canvasElements[0].props.shapes[0].type).toBe("eyes") + expect(canvasElements[0].props.shapes[0].glanceX).toBe(5) + expect(canvasElements[0].props.shapes[0].blinkLoop).toBe(2.0) + + expect(canvasElements[1].props.shapes[0].type).toBe("stroke") + expect(canvasElements[1].props.shapes[0].wiggle).toBe(3) + expect(canvasElements[1].props.shapes[0].wiggleLoop).toBe(1.5) + }) +}) diff --git a/packages/react/src/__tests__/motion-spring.test.ts b/packages/react/src/__tests__/motion-spring.test.ts new file mode 100644 index 00000000..70497f42 --- /dev/null +++ b/packages/react/src/__tests__/motion-spring.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { GELATIN, stepSpring, type SpringTrack } from "../motion-spring.js" + +describe("stepSpring", () => { + it("snaps to rest when inside the rest window", () => { + const next = stepSpring({ pos: 100.02, vel: 0.01 }, 100, 1 / 60, 28, 8, 1.25) + expect(next).toEqual({ pos: 100, vel: 0 }) + }) + + it("overshoots with GELATIN then settles on the target", () => { + let track: SpringTrack = { pos: 0, vel: 0 } + let max = 0 + for (let i = 0; i < 240; i++) { + track = stepSpring( + track, + 100, + 1 / 60, + GELATIN.stiffness, + GELATIN.damping, + GELATIN.mass + ) + max = Math.max(max, track.pos) + } + expect(max).toBeGreaterThan(100) + expect(track.pos).toBe(100) + expect(track.vel).toBe(0) + }) +}) diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 548e7ea5..513e3b25 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,8 +1,16 @@ // GPUIX component definitions and native motion wrappers. -import { createElement, forwardRef } from "react" +import { createElement, forwardRef, useEffect, useRef, useState } from "react" import type { ReactElement, ReactNode } from "react" -import type { MotionProps, Props, PublicInstance, StyleDesc } from "../types/host.js" +import type { + MotionProps, + MotionSpringTransition, + MotionStyle, + Props, + PublicInstance, + StyleDesc, +} from "../types/host.js" +import { GELATIN, onFrame, stepSpring, type SpringTrack } from "../motion-spring.js" export const gpuixComponents = { div: "div", @@ -36,13 +44,99 @@ export interface MotionDivProps extends MotionProps { autoFocus?: boolean } +const SPRING_KEYS = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +] as const + +type SpringKey = (typeof SPRING_KEYS)[number] + +function isSpringTransition( + transition: MotionProps["transition"] +): transition is MotionSpringTransition { + return transition != null && transition.type === "spring" +} + +function readStyle(style: MotionStyle | false | undefined, key: SpringKey): number | undefined { + if (style == null || style === false) return undefined + return style[key] +} + const MotionDiv = forwardRef(function MotionDiv( - { initial, animate, transition, ...props }, + { initial, animate, transition, style, ...props }, ref ): ReactElement { + const spring = isSpringTransition(transition) + const animateRef = useRef(animate) + animateRef.current = animate + const [current, setCurrent] = useState(() => { + const seed: MotionStyle = {} + for (const key of SPRING_KEYS) { + const value = readStyle(initial, key) ?? animate[key] + if (value != null) seed[key] = value + } + return seed + }) + const tracks = useRef>>({}) + const transitionRef = useRef(transition) + transitionRef.current = transition + + useEffect(() => { + if (!spring) return + return onFrame((dt) => { + const spec = transitionRef.current + if (!isSpringTransition(spec)) return + const stiffness = spec.stiffness ?? GELATIN.stiffness + const damping = spec.damping ?? GELATIN.damping + const mass = spec.mass ?? GELATIN.mass + const kick = spec.velocity ?? 0 + const target = animateRef.current + let changed = false + const next: MotionStyle = {} + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + const rest = key === "opacity" ? 0.002 : 0.05 + let track = tracks.current[key] + if (!track) track = { pos: to, vel: kick } + const stepped = stepSpring(track, to, dt, stiffness, damping, mass, rest) + if (stepped.pos !== track.pos || stepped.vel !== track.vel) changed = true + tracks.current[key] = stepped + next[key] = stepped.pos + } + if (changed) setCurrent(next) + }) + }, [spring]) + + if (spring) { + const hostProps: Props = { + ...props, + ref, + style: { + ...(style ?? {}), + ...(current.width != null ? { width: current.width } : {}), + ...(current.height != null ? { height: current.height } : {}), + ...(current.opacity != null ? { opacity: current.opacity } : {}), + ...(current.top != null ? { top: current.top } : {}), + ...(current.right != null ? { right: current.right } : {}), + ...(current.bottom != null ? { bottom: current.bottom } : {}), + ...(current.left != null ? { left: current.left } : {}), + ...(current.borderRadius != null ? { borderRadius: current.borderRadius } : {}), + }, + } + return createElement("div", hostProps) + } + const hostProps: Props = { ...props, ref, + style, motion: { ...(initial === undefined ? {} : { initial }), animate, @@ -56,9 +150,3 @@ const MotionDiv = forwardRef(function MotionDiv( export const motion = { div: MotionDiv, } as const - -// There is no `VirtualList` React wrapper. Windowing on the React side is the -// app's job: pass `itemCount`, `estimatedItemHeight` and `windowStart` to the -// host `` and render only that slice. A generic wrapper cannot -// know when to widen its own window, so it silently dropped rows whenever -// `itemCount` grew without a scroll. diff --git a/packages/react/src/components/marks.tsx b/packages/react/src/components/marks.tsx new file mode 100644 index 00000000..c42d474b --- /dev/null +++ b/packages/react/src/components/marks.tsx @@ -0,0 +1,63 @@ +import { createElement, forwardRef } from "react" +import type { PublicInstance, Props } from "../types/host.js" +import { motion } from "./index.js" + +export interface CanvasPathCommand { + type: "move" | "line" | "curve" | "close" + x?: number + y?: number + x1?: number + y1?: number + x2?: number + y2?: number +} + +export interface CanvasShape { + type?: "path" | "body" | "eyes" | "stroke" + path?: CanvasPathCommand[] + fill?: string + stroke?: string + strokeWidth?: number + + // Body props + squash?: number + breatheLoop?: number // speed multiplier + + // Eyes props + glanceX?: number + glanceY?: number + blink?: number + blinkLoop?: number + + // Stroke props + wiggle?: number + wiggleLoop?: number +} + +export interface CanvasProps extends Props { + shapes?: CanvasShape[] +} + +export const Canvas = forwardRef(function Canvas( + props, + ref +) { + return createElement("canvas", { ...props, ref }) +}) + + +export function VectorPath({ path, fill, stroke, strokeWidth }: { path: CanvasPathCommand[], fill?: string, stroke?: string, strokeWidth?: number }) { + return +} + +export function OrganicBody({ path, fill, squash = 0, breatheLoop, stroke, strokeWidth }: { path: CanvasPathCommand[], fill?: string, stroke?: string, strokeWidth?: number, squash?: number, breatheLoop?: number }) { + return +} + +export function VectorEyes({ path, stroke, strokeWidth, glanceX = 0, glanceY = 0, blink = 0, blinkLoop }: { path: CanvasPathCommand[], stroke: string, strokeWidth?: number, glanceX?: number, glanceY?: number, blink?: number, blinkLoop?: number }) { + return +} + +export function DecorativeStroke({ path, stroke, strokeWidth, wiggle = 0, wiggleLoop }: { path: CanvasPathCommand[], stroke: string, strokeWidth?: number, wiggle?: number, wiggleLoop?: number }) { + return +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b8e68ff5..1ad6aca0 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -65,7 +65,10 @@ export type { TooltipProviderProps, TooltipTriggerProps, } from "./components/tooltip.js" +export { Canvas, VectorPath, OrganicBody, VectorEyes, DecorativeStroke } from "./components/marks.js" +export type { CanvasPathCommand, CanvasShape, CanvasProps } from "./components/marks.js" export { motion } from "./components/index.js" +export { onFrame, stepSpring, GELATIN } from "./motion-spring.js" export type { Root, FrameLoop, RenderOptions } from "./reconciler/renderer.js" export type { WindowInsets, @@ -89,6 +92,8 @@ export type { MotionProps, MotionStyle, MotionTransition, + MotionSpringTransition, + MotionTweenTransition, NativeRenderer, NativeWindowInsets, PublicInstance, diff --git a/packages/react/src/motion-spring.ts b/packages/react/src/motion-spring.ts new file mode 100644 index 00000000..0cb10c16 --- /dev/null +++ b/packages/react/src/motion-spring.ts @@ -0,0 +1,41 @@ +/** Semi-implicit Euler spring. Runs on the GPUIX frame loop, not CSS tweens. */ + +export type SpringTrack = { pos: number; vel: number } + +export function stepSpring( + track: SpringTrack, + target: number, + dt: number, + stiffness: number, + damping: number, + mass: number, + rest = 0.05 +): SpringTrack { + const clamped = Math.min(Math.max(dt, 0), 0.032) + const m = Math.max(mass, 0.001) + const x = track.pos - target + const accel = (-stiffness * x - damping * track.vel) / m + const vel = track.vel + accel * clamped + const pos = track.pos + vel * clamped + if (Math.abs(pos - target) < rest && Math.abs(vel) < rest) { + return { pos: target, vel: 0 } + } + return { pos, vel } +} + +export type FrameListener = (dt: number, now: number) => void + +const listeners = new Set() + +export function onFrame(listener: FrameListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function pumpFrames(dt: number, now: number): void { + for (const listener of listeners) listener(dt, now) +} + +export const GELATIN = { stiffness: 28, damping: 8, mass: 1.25, velocity: 0 } diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index e46224b4..a4317905 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -3,6 +3,7 @@ import { GpuixRenderer } from "@gpuix/native" import type { EventPayload, WindowOptions } from "@gpuix/native" import { createRoot, flushSync, type Root } from "./reconciler.js" import type { DebugFrameOverlayMode, NativeRenderer } from "../types/host.js" +import { pumpFrames } from "../motion-spring.js" import { handleGpuixEvent } from "./event-registry.js" import { App as AutomationApp, @@ -96,9 +97,12 @@ export function startFrameLoop( timer = null } + let lastFrame = performance.now() const loop = (): void => { if (stopped) return const started = performance.now() + pumpFrames(Math.min((started - lastFrame) / 1000, 0.032), started) + lastFrame = started const running = renderer.tick() if (running === false) { stop() diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index e16fe2b9..43073351 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -21,7 +21,8 @@ export type MotionEase = | "easeInOut" | [number, number, number, number] -export interface MotionTransition { +export interface MotionTweenTransition { + type?: "tween" /** Duration in seconds. */ duration?: number /** Delay in seconds. */ @@ -29,6 +30,18 @@ export interface MotionTransition { ease?: MotionEase } +/** Critically-underdamped integrator. Defaults are gelatinous, not snappy. */ +export interface MotionSpringTransition { + type: "spring" + stiffness?: number + damping?: number + mass?: number + velocity?: number + delay?: number +} + +export type MotionTransition = MotionTweenTransition | MotionSpringTransition + export interface MotionProps { initial?: MotionStyle | false animate: MotionStyle