diff --git a/.changeset/view-transitions.md b/.changeset/view-transitions.md new file mode 100644 index 00000000..7eced04c --- /dev/null +++ b/.changeset/view-transitions.md @@ -0,0 +1,31 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Add the View Transitions API. + +`startViewTransition(renderer, update, options)` captures every element that +carries a `viewTransitionName`, applies the React update synchronously, and +animates each name from its old place to its new one. The renderer clones the +named subtrees before the update and paints the frozen copies over the live +tree while the transition runs, so the leaving screen stays visible under, or +over, the arriving one. + +Options take a duration, a delay, and an ease per name, plus `translateX`, +`translateY`, `opacity` and `blur` ranges for the old side and the new side. +Percent lengths resolve against the size of the named element, so +`translateX: ["100%", "0%"]` slides a screen in from the right at any width. +A name with no options crossfades. A name that only enters animates against +its own bounds. A name that only leaves paints its frozen copy over the tree +while the `old` side runs, without the clip of its former ancestors. + +The `motion` prop takes a `blur` field too: a `filter: blur()` sigma in +pixels that interpolates like `opacity`. + +The new side moves through the motion channel, so the live element and its +hitboxes move together, and input lands where the screen paints. The frozen +copy takes fresh ids where the live tree still uses them, so a surviving +element and its copy never share GPUI element state. + +Limits in this version: the frozen copy takes no input. diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 60a97b98..7c925d65 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -23,6 +23,7 @@ import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" import { motion } from "@gpuix/react" import { Motion } from "./demo/motion-panel" +import { Navigation } from "./demo/navigation" import { IntoView, Scrollbars } from "./demo/scrollbars" import { Selectors } from "./demo/selectors" import { Variables } from "./demo/variables" @@ -51,6 +52,7 @@ const PANELS = [ ["selectors", ], ["motion", ], ["scrollbars", ], + ["navigation", ], ] as const describeNative("demo panels", () => { @@ -322,6 +324,50 @@ describeNative("the scrollbars panel", () => { }) }) +describeNative("the navigation panel", () => { + it("pushes the General screen from the right and pops it back", () => { + const test = root() + test.render( +
+ +
+ ) + test.renderer.clockPause() + + const general = test.renderer.findByTestId("nav-row-General")! + const [gx, gy] = test.renderer.getElementBounds(general.id)! + test.renderer.nativeSimulateClick(gx + 4, gy + 4) + + // At the start of the push, the General screen sits one screen width to + // the right of where it will rest. The phone is 320 wide with a 1px + // border on each side, so the screen is 318. + const about = test.renderer.findByText("About")! + const startX = test.renderer.getElementBounds(about.id)![0] + test.renderer.clockFastForward(600) + const endX = test.renderer.getElementBounds(about.id)![0] + expect(startX - endX).toBeCloseTo(318, 0) + + const back = test.renderer.findByTestId("nav-back")! + const [bx, by] = test.renderer.getElementBounds(back.id)! + test.renderer.nativeSimulateClick(bx + 4, by + 4) + test.renderer.clockFastForward(600) + expect(test.renderer.findByTestId("nav-row-General")).toBeDefined() + expect(test.renderer.findByText("About")).toBeUndefined() + + test.renderer.clockResume() + test.unmount() + }) +}) + describeNative("the whole application", () => { /// Walk the sidebar and paint each section, so the whole application is /// covered rather than the one it opens on. The test renderer has the frame @@ -331,7 +377,7 @@ describeNative("the whole application", () => { test.render() expect(test.renderer.getPaintedText()).toContain("GPUIX") - for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Performance", "Colours"]) { + for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Navigation", "Performance", "Colours"]) { const item = test.renderer.findByText(title) expect(item, `no sidebar item named ${title}`).toBeDefined() const bounds = test.renderer.getElementBounds(item!.id) diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx index d5f7a2e2..d5a9c874 100644 --- a/examples/demo/app.tsx +++ b/examples/demo/app.tsx @@ -16,6 +16,7 @@ import { Effects } from "./effects.js" import { Inheritance } from "./inheritance.js" import { Lengths } from "./lengths.js" import { Motion } from "./motion-panel.js" +import { Navigation } from "./navigation.js" import { frameOverlay, Perf } from "./perf.js" import { Scrollbars } from "./scrollbars.js" import { Selectors } from "./selectors.js" @@ -80,6 +81,7 @@ const SECTIONS = [ { id: "selectors", title: "Selectors", render: () => }, { id: "motion", title: "Motion", render: () => }, { id: "scrollbars", title: "Scrollbars", render: () => }, + { id: "navigation", title: "Navigation", render: () => }, ] as const type SectionId = (typeof SECTIONS)[number]["id"] | "perf" diff --git a/examples/demo/navigation.tsx b/examples/demo/navigation.tsx new file mode 100644 index 00000000..9ddf384c --- /dev/null +++ b/examples/demo/navigation.tsx @@ -0,0 +1,220 @@ +/// View transitions, shown as the push and pop of the iOS Settings app. +/// +/// The header stays mounted the whole time, and only its content takes part +/// in the transition. The back button enters and leaves through a blur and +/// opacity pair. The title of each screen carries the name "nav-title", so +/// the old text blurs and fades out in place while the new text sharpens in, +/// a text morph. The screens slide under the header as a pair, and a +/// backdrop blur with an eased mask blurs the rows progressively where they +/// pass under it. + +import React, { useState } from "react" +import { startViewTransition, useGpuix } from "@gpuix/react" +import type { NativeRenderer, ViewTransitionOptions } from "@gpuix/react" +import { Panel } from "./ui.js" + +const HEADER_HEIGHT = 56 +/// How far past the bar the backdrop blur fades out. +const BLUR_TAIL = 28 +/// A spring without bounce: fast out of the gate, a long soft landing, +/// and no overshoot, like a critically damped UIKit spring. +const SPRING: [number, number, number, number] = [0.36, 0.66, 0.04, 1] + +const PUSH: ViewTransitionOptions = { + duration: 0.45, + ease: SPRING, + groups: { + screen: { + old: { translateX: ["0%", "-30%"] }, + new: { translateX: ["100%", "0%"] }, + }, + "nav-back": { new: { opacity: [0, 1], blur: [6, 0] } }, + "nav-title": { + old: { opacity: [1, 0], blur: [0, 4] }, + new: { opacity: [0, 1], blur: [4, 0] }, + }, + }, +} + +const POP: ViewTransitionOptions = { + duration: 0.45, + ease: SPRING, + groups: { + screen: { + old: { translateX: ["0%", "100%"], onTop: true }, + new: { translateX: ["-30%", "0%"] }, + }, + "nav-back": { old: { opacity: [1, 0], blur: [0, 6] } }, + "nav-title": { + old: { opacity: [1, 0], blur: [0, 4] }, + new: { opacity: [0, 1], blur: [4, 0] }, + }, + }, +} + +const ROOT_ROWS = [ + "General", "Display", "Sound", "Focus", "Battery", + "Privacy", "Wallpaper", "Siri", "Wallet", "Accounts", + "App Store", "Game Center", "Developer", +] +const GENERAL_ROWS = [ + "About", "Software Update", "Storage", "AppleCare", "AirDrop", + "AirPlay", "Picture in Picture", "CarPlay", "Keyboard", "Fonts", + "Language", "Dictionary", "VPN", "Legal", +] + +function NavRow({ label, detail, onClick }: { + label: string + detail?: string + onClick?: () => void +}) { + return ( +
+ {label} + {detail ?? (onClick ? ">" : "")} +
+ ) +} + +/// The header that never unmounts. The first layer is the progressive blur: +/// a backdrop blur whose eased mask fades it out past the bar, so the rows +/// blur where they pass under it. The title and the back button sit on top +/// of that layer, and each carries its own view transition name. +function Header({ screen, onBack }: { + screen: "root" | "general" + onBack: () => void +}) { + const title = screen === "root" ? "Settings" : "General" + return ( + <> +
+
+
+ {title} +
+
+
+ {screen === "general" ? ( +
+ {"< Settings"} +
+ ) : null} +
+ + ) +} + +/// One screen of the stack. The name pairs it with the screen it replaces, +/// and the key makes React mount a new element instead of an update in +/// place, the way a real navigation swaps components. The top padding puts +/// the first row under the header, and the rows scroll under it. +function Screen({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Phone({ renderer }: { renderer: NativeRenderer | null }) { + const [screen, setScreen] = useState<"root" | "general">("root") + const go = (next: "root" | "general", options: ViewTransitionOptions) => { + if (renderer) { + startViewTransition(renderer, () => setScreen(next), options) + } else { + setScreen(next) + } + } + + return ( +
+ {screen === "root" ? ( + + {ROOT_ROWS.map((label) => ( + go("general", PUSH) : undefined} + /> + ))} + + ) : ( + + {GENERAL_ROWS.map((label) => ( + + ))} + + )} +
go("root", POP)} /> +
+ ) +} + +export function Navigation() { + const { renderer } = useGpuix() + return ( + + + + ) +} diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index eaf27b81..117f49f7 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -75,6 +75,17 @@ export declare class GpuixRenderer { * box apply. */ scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void + /** + * Clone every element that has a `viewTransitionName`, with its painted + * bounds. Call this before the React update, then `viewTransitionStart` + * after it. `startViewTransition` in `@gpuix/react` does both. + */ + viewTransitionCapture(): void + /** + * Animate every captured name toward its new element. `options` is the + * JSON of a `ViewTransitionOptions` value, or nothing for a crossfade. + */ + viewTransitionStart(options?: string | undefined | null): void /** Hidden → minimal → full → hidden. */ cycleDebugFrameOverlay(): string getDebugFrameOverlay(): string @@ -337,6 +348,16 @@ export declare class TestGpuixRenderer { * web scrollIntoView. Call flush() after to apply and re-render. */ scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void + /** + * Clone every element that has a `viewTransitionName`, with its painted + * bounds. Call flush() first, so the bounds are current. + */ + viewTransitionCapture(): void + /** + * Animate every captured name toward its new element. Call flush() + * after, and move the automation clock to step through the frames. + */ + viewTransitionStart(options?: string | undefined | null): void /** * Scroll a child into view by its index in the children list. * Call flush() after to apply and re-render. For a `` the diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index 69d0e99c..816dcc44 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -15,6 +15,8 @@ pub(crate) struct MotionStyle { pub width: Option, pub height: Option, pub opacity: Option, + /// A `filter: blur()` sigma in pixels, on the element and its children. + pub blur: Option, pub top: Option, pub right: Option, pub bottom: Option, @@ -180,7 +182,7 @@ impl MotionHeight { } /// One step of a linear interpolation. -fn mix(from: f64, to: f64, progress: f64) -> f64 { +pub(crate) fn mix(from: f64, to: f64, progress: f64) -> f64 { from + (to - from) * progress } @@ -198,6 +200,7 @@ impl MotionStyle { .height .map(|to| self.height.unwrap_or(to).mix(to, progress)), opacity: value(self.opacity, target.opacity, progress), + blur: value(self.blur, target.blur, progress), top: value(self.top, target.top, progress), right: value(self.right, target.right, progress), bottom: value(self.bottom, target.bottom, progress), @@ -221,6 +224,9 @@ impl MotionStyle { if let Some(value) = self.opacity { style.opacity = Some(value.into()); } + if let Some(value) = self.blur { + style.filter = Some(format!("blur({value}px)")); + } if let Some(value) = self.top { style.top = Some(value.into()); } @@ -251,7 +257,7 @@ enum MotionInitial { #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(untagged)] -enum MotionEase { +pub(crate) enum MotionEase { Name(String), CubicBezier([f64; 4]), } @@ -329,6 +335,39 @@ impl MotionFrame { pub(crate) fn measured_height(&self) -> Option { self.style.height.filter(|height| height.needs_content()) } + + /// A frame a view transition composes for the arriving element of a pair. + /// It carries the opacity and the blur of this animation frame. The + /// transition element applies the movement at paint. + pub(crate) fn view_transition_frame(opacity: Option, blur: Option) -> Self { + Self { + style: MotionStyle { + opacity, + blur, + ..MotionStyle::default() + }, + active: true, + content: None, + measured: ContentHeight::default(), + } + } + + /// Fold a view transition into this frame. The transition owns the + /// element while it runs, so its values replace the motion ones. + pub(crate) fn with_view_transition( + mut self, + opacity: Option, + blur: Option, + ) -> Self { + if opacity.is_some() { + self.style.opacity = opacity; + } + if blur.is_some() { + self.style.blur = blur; + } + self.active = true; + self + } } pub(crate) struct MotionState { @@ -500,6 +539,7 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> { ("width", style.width), ("height", style.height.map(|height| height.pixels)), ("opacity", style.opacity), + ("blur", style.blur), ("top", style.top), ("right", style.right), ("bottom", style.bottom), @@ -513,8 +553,9 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> { if style.width.is_some_and(|value| value < 0.0) || style.height.is_some_and(|height| height.pixels < 0.0) || style.border_radius.is_some_and(|value| value < 0.0) + || style.blur.is_some_and(|value| value < 0.0) { - return Err("motion sizes and borderRadius must be non-negative".to_string()); + return Err("motion sizes, borderRadius and blur must be non-negative".to_string()); } if style .opacity @@ -534,7 +575,7 @@ fn validate_seconds(value: f64, name: &str) -> Result<(), String> { Ok(()) } -fn validate_ease(ease: &MotionEase) -> Result<(), String> { +pub(crate) fn validate_ease(ease: &MotionEase) -> Result<(), String> { match ease { MotionEase::Name(name) if matches!( @@ -561,7 +602,7 @@ fn seconds(value: f64) -> Duration { Duration::try_from_secs_f64(value).expect("motion durations are validated when parsed") } -fn ease(progress: f64, ease: &MotionEase) -> f64 { +pub(crate) fn ease(progress: f64, ease: &MotionEase) -> f64 { let curve = match ease { MotionEase::CubicBezier(curve) => *curve, MotionEase::Name(name) => match name.as_str() { @@ -664,6 +705,25 @@ mod tests { ); } + #[test] + fn blur_interpolates_and_folds_into_the_filter() { + let started = Instant::now(); + let spec = serde_json::json!({ + "initial": { "blur": 0.0 }, + "animate": { "blur": 8.0 }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + let state = MotionState::new(&spec, started).unwrap(); + let frame = state.frame(started + Duration::from_millis(500)); + assert_eq!(frame.style.blur, Some(4.0)); + let mut style = StyleDesc::default(); + frame.style.apply_to(&mut style); + assert_eq!(style.filter.as_deref(), Some("blur(4px)")); + + let bad = serde_json::json!({ "animate": { "blur": -1.0 }, "transition": {} }); + assert!(MotionState::new(&bad, started).is_err()); + } + #[test] fn disabled_initial_state_starts_at_the_target() { let now = Instant::now(); diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 213054f5..09bafe35 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -50,6 +50,7 @@ mod batch; mod frame; pub(crate) mod scroll_into_view; pub(crate) mod scrollbar; +pub(crate) mod view_transition; mod virtual_list; pub use batch::apply_batch_to_tree; @@ -346,6 +347,10 @@ enum UiCommand { block: scroll_into_view::Align, inline: scroll_into_view::Align, }, + ViewTransitionCapture, + ViewTransitionStart { + options: String, + }, GetScrollOffset { id: u64, response: SyncSender>, @@ -494,6 +499,19 @@ async fn run_ui_commands( .ok(); refresh_ui_window(window, cx) } + UiCommand::ViewTransitionCapture => window.update(cx, |view, _window, _cx| { + view.view_transition_capture(); + }), + UiCommand::ViewTransitionStart { options } => { + window + .update(cx, move |view, _window, _cx| { + if let Err(error) = view.view_transition_start(&options) { + log::warn!("Invalid view transition options: {error}"); + } + }) + .ok(); + refresh_ui_window(window, cx) + } UiCommand::GetScrollOffset { id, response } => { let offset = VIRTUAL_LIST_STATES .with(|cell| { @@ -1382,6 +1400,51 @@ impl GpuixRenderer { Err(Error::from_reason("Unsupported operating system")) } + /// Clone every element that has a `viewTransitionName`, with its painted + /// bounds. Call this before the React update, then `viewTransitionStart` + /// after it. `startViewTransition` in `@gpuix/react` does both. + #[napi] + pub fn view_transition_capture(&self) -> Result<()> { + #[cfg(target_os = "macos")] + return update_window(|view, _window, _cx| view.view_transition_capture()); + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + return self.send_ui_command(UiCommand::ViewTransitionCapture); + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Err(Error::from_reason("Unsupported operating system")) + } + + /// Animate every captured name toward its new element. `options` is the + /// JSON of a `ViewTransitionOptions` value, or nothing for a crossfade. + #[napi] + pub fn view_transition_start(&self, options: Option) -> Result<()> { + let options = options.unwrap_or_else(|| "{}".to_string()); + #[cfg(target_os = "macos")] + { + update_window(move |view, _window, _cx| { + view.view_transition_start(&options).map_err(Error::from_reason) + })??; + return invalidate_window(); + } + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + return self.send_ui_command(UiCommand::ViewTransitionStart { options }); + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Err(Error::from_reason("Unsupported operating system")) + } + /// Hidden → minimal → full → hidden. #[napi] pub fn cycle_debug_frame_overlay(&self) -> Result { @@ -2930,6 +2993,10 @@ pub(crate) struct GpuixView { /// Resolved `highlight` state, keyed by the element that declared it. /// Empty in every app that does not use search. highlights: HashMap, + /// The running view transition, when one runs. + view_transition: Option, + /// Captures `viewTransitionCapture` took, waiting for the start call. + pending_view_transition: Option>, } /// Two-level cache for one element's `highlight`. @@ -3063,9 +3130,33 @@ impl GpuixView { clock: crate::automation::AutomationClock::new(), root_cascade: RefCell::new(None), highlights: HashMap::new(), + view_transition: None, + pending_view_transition: None, } } + /// Clone every named element and its painted bounds, before the tree + /// swaps. `view_transition_start` consumes the result. + pub(crate) fn view_transition_capture(&mut self) { + let tree = self.tree.lock().unwrap(); + self.pending_view_transition = Some(view_transition::capture(&tree)); + } + + /// Start the transition against the tree as it is now. A start without a + /// capture still animates the names the new tree carries. + pub(crate) fn view_transition_start( + &mut self, + options_json: &str, + ) -> std::result::Result<(), String> { + let options = view_transition::VtOptions::parse(options_json)?; + let captures = self.pending_view_transition.take().unwrap_or_default(); + let tree = self.tree.lock().unwrap(); + let state = view_transition::VtState::new(captures, options, &tree); + drop(tree); + self.view_transition = Some(state); + Ok(()) + } + /// The root cascade for `theme`, reusing the last one while the theme /// holds still. fn root_cascade(&self, theme: &Theme, rem_size: gpui::Pixels) -> crate::inheritance::Inherited { @@ -3176,6 +3267,8 @@ impl GpuixView { highlight, highlights: &mut self.highlights, highlight_events: &mut highlight_events, + vt: self.view_transition.as_ref(), + frozen: false, direct_rules: Vec::new(), descendant_rules: Vec::new(), }; @@ -3389,25 +3482,38 @@ impl gpui::Render for GpuixView { // Sync focus handles before building elements. self.sync_focus_handles(&tree, &callback, window, cx); + // One frame of the running view transition, taken out of `self` so + // the build can borrow the rest of the view. A transition past its + // end drops here, and the frame paints the live tree alone. + let now = self.clock.now(); + let view_transition = self.view_transition.take().and_then(|mut transition| { + transition.tick(now).then_some(transition) + }); + let kept_by_transition = |id: &u64| { + view_transition + .as_ref() + .is_some_and(|transition| transition.keeps(*id)) + }; + // Ensure custom element instances are destroyed when their IDs disappear. + // A frozen view-transition copy keeps its instances until it fades. self.custom_registry - .prune_missing(|id| tree.elements.contains_key(&id)); + .prune_missing(|id| tree.elements.contains_key(&id) || kept_by_transition(&id)); // Clean up scroll handles for destroyed elements (IDs removed from tree). // Scrollability-based cleanup (element still exists but style changed // from scroll to non-scroll) is handled inside build_host_container(). self.scroll_handles - .retain(|id, _| tree.elements.contains_key(id)); + .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id)); self.virtual_lists - .retain(|id, _| tree.elements.contains_key(id)); + .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id)); self.motion_states - .retain(|id, _| tree.elements.contains_key(id)); + .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id)); // Build the element tree. custom_registry, focus_handles, and scroll_handles // are different fields of self, so Rust allows borrowing all simultaneously. let theme = Theme::dark(); let root_cascade = self.root_cascade(&theme, window.rem_size()); - let now = self.clock.now(); let mut motion_active = false; // Pruned by DECLARATION, not existence: an element that drops its // `highlight` prop keeps living, and its cached group list holds a copy @@ -3418,7 +3524,7 @@ impl gpui::Render for GpuixView { .is_some_and(|element| element.custom_props.contains_key("highlight")) }); let mut highlight_events = Vec::new(); - let result = match tree.root_id { + let (result, exit_copies) = match tree.root_id { Some(root_id) => { let mut ctx = BuildCtx { tree: &tree, @@ -3436,13 +3542,24 @@ impl gpui::Render for GpuixView { highlight: None, highlights: &mut self.highlights, highlight_events: &mut highlight_events, + vt: view_transition.as_ref(), + frozen: false, direct_rules: Vec::new(), descendant_rules: Vec::new(), }; - build_element(root_id, None, &mut ctx, window, cx) + let root = build_element(root_id, None, &mut ctx, window, cx); + // Names captured without a successor paint as frozen copies + // over the tree. Built after the root, so they paint last. + let copies = view_transition::exit_copies(&mut ctx, window, cx); + (root, copies) } - None => gpui::Empty.into_any_element(), + None => (gpui::Empty.into_any_element(), Vec::new()), }; + // A transition animates every frame until it comes to rest. + if view_transition.is_some() { + motion_active = true; + } + self.view_transition = view_transition; // Flushed after the root build so a `setState` in the handler cannot // re-enter this build. emit_highlight_events(&callback, &highlight_events); @@ -3461,6 +3578,7 @@ impl gpui::Render for GpuixView { .child(selection_frame_reset(self.selection.clone())) .child(crate::automation::bounds_frame_reset()) .child(result) + .children(exit_copies) .into_any_element() }; diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 47222874..a3f5d4db 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -49,6 +49,14 @@ pub(super) struct BuildCtx<'a> { /// would re-enter the build and emit again. They are flushed once the root /// build has returned. pub highlight_events: &'a mut Vec<(u64, usize)>, + /// The running view transition, or `None`. Cleared inside the build of a + /// frozen copy, so a name inside the copy never starts a nested one. + pub vt: Option<&'a super::view_transition::VtState>, + /// Whether this build is the frozen copy of a view transition. A copy is + /// a still image, so it gets no scrollbar. The scrollbar defers its draw, + /// and a deferred draw from inside the copy's isolated layout would read + /// its layout ids against the window's tree and panic. + pub frozen: bool, /// Rules the parent puts on its direct children, `& > *` and /// `& > :not(:last-child)`. They reach one depth only, so every element /// swaps in its own set, possibly empty, before it builds its children. @@ -105,6 +113,28 @@ pub(super) fn build_element( }; let style = element.style.as_deref(); + // This frame of the view transition, when one runs and the element + // carries a name. The opacity and blur of the arriving side fold into the + // motion channel here, and the movement applies at paint in the wrapper + // below. + let vt_frame = ctx.vt.and_then(|vt| { + let name = style?.view_transition_name.as_deref()?; + if name.is_empty() || name == "none" { + return None; + } + vt.frame_for(name) + }); + let motion = match vt_frame + .as_ref() + .map(|frame| (frame.new_opacity(), frame.new_blur())) + { + Some((opacity, blur)) if opacity.is_some() || blur.is_some() => Some(match motion { + Some(frame) => frame.with_view_transition(opacity, blur), + None => crate::motion::MotionFrame::view_transition_frame(opacity, blur), + }), + _ => motion, + }; + // Inheritable style resolves before the element's own style, because a // custom property declared here is in scope for the `var()` next to it. let parent_cascade = ctx.cascade.clone(); @@ -210,6 +240,10 @@ pub(super) fn build_element( }; let built = super::auto_height::wrap(id, built, motion.as_ref(), resolved.as_deref()); + let built = match vt_frame { + Some(frame) => super::view_transition::wrap(element, built, frame, ctx, window, cx), + None => built, + }; ctx.cascade = parent_cascade; ctx.highlight = parent_highlight; @@ -526,9 +560,12 @@ pub(crate) fn build_host_container( el = el.track_scroll(handle); // The scrollbar. Classic bars reserve a gutter in the layout, - // which taffy takes as one width for both axes. + // which taffy takes as one width for both axes. A frozen view + // transition copy gets none: see `BuildCtx::frozen`. let mode = super::scrollbar::Mode::current(cx); - if let Some(spec) = super::scrollbar::Spec::from_style(style, mode) { + if let Some(spec) = + super::scrollbar::Spec::from_style(style, mode).filter(|_| !ctx.frozen) + { let state = ctx.scrollbars.entry(element.id).or_default().clone(); let reserved = spec.reserved(state.borrow().overflowed); let gutter = reserved.x.max(reserved.y); diff --git a/packages/native/src/renderer/view_transition.rs b/packages/native/src/renderer/view_transition.rs new file mode 100644 index 00000000..13a4a28b --- /dev/null +++ b/packages/native/src/renderer/view_transition.rs @@ -0,0 +1,848 @@ +//! View transitions: freeze the named elements, swap the tree, then animate +//! each name from its old place to its new one. +//! +//! `viewTransitionCapture` clones the subtree and the painted bounds of every +//! element that has a `viewTransitionName`. `viewTransitionStart` parses the +//! options and starts the clock. While the transition runs, `build_element` +//! wraps each named live element in a `VtGroup`. The group takes the layout of +//! the live element, so the transition never disturbs the surrounding layout. +//! It paints the frozen copy at its captured place, then paints the live +//! element moved by this frame's offset. Opacity for the live element rides +//! the same style channel that `motion` uses, and the frozen copy carries its +//! opacity on a wrapper element. +//! +//! A name that disappears without a successor becomes an exit copy: the +//! renderer paints its frozen copy over the whole tree at its captured +//! place, and the group's `old` side drives it. +//! +//! Known limits, on purpose: +//! - An exit copy paints over the tree, so a former ancestor's clip or +//! scroll no longer applies to it. Give both screens one name when the +//! exit must stay inside the element's own area. +//! - When the named element survives the swap, its frozen copy takes fresh +//! ids, so the copy paints without the old scroll offsets. +//! - The frozen copy keeps its event listeners, but their elements are gone +//! on the React side, so input over the copy does nothing. + +use std::collections::{HashMap, HashSet}; + +use gpui::{ + AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, IsolatedLayout, LayoutId, Pixels, Point, Size, Window, point, + px, size, +}; +use serde::Deserialize; +use web_time::Instant; + +use super::frame::{build_element, BuildCtx}; +use crate::motion::{self, MotionEase}; +use crate::retained_tree::{RetainedElement, RetainedTree}; + +// ── Options ────────────────────────────────────────────────────────── + +/// A translation distance: pixels, or a share of the element's size. +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(try_from = "LenWire")] +pub(crate) enum VtLen { + Px(f64), + Percent(f64), +} + +impl VtLen { + fn resolve(self, extent: f64) -> f64 { + match self { + Self::Px(value) => value, + Self::Percent(value) => value / 100.0 * extent, + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum LenWire { + Number(f64), + Text(String), +} + +impl TryFrom for VtLen { + type Error = String; + + fn try_from(wire: LenWire) -> Result { + let text = match wire { + LenWire::Number(value) if value.is_finite() => return Ok(Self::Px(value)), + LenWire::Number(value) => { + return Err(format!("view transition length must be finite, got {value}")) + } + LenWire::Text(text) => text, + }; + let trimmed = text.trim(); + let (number, percent) = match trimmed.strip_suffix('%') { + Some(number) => (number, true), + None => (trimmed.strip_suffix("px").unwrap_or(trimmed), false), + }; + let value = number + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .ok_or_else(|| format!("bad view transition length: {text:?}"))?; + Ok(if percent { + Self::Percent(value) + } else { + Self::Px(value) + }) + } +} + +/// What one side of a pair does over the transition. Every field is a +/// `[from, to]` pair. A missing field holds still. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct SideSpec { + translate_x: Option<[VtLen; 2]>, + translate_y: Option<[VtLen; 2]>, + opacity: Option<[f64; 2]>, + /// A `filter: blur()` sigma in pixels, as a `[from, to]` pair. + blur: Option<[f64; 2]>, + /// Paint this side over the other one. Only read on the old side. + on_top: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct GroupSpec { + duration: Option, + delay: Option, + ease: Option, + old: Option, + new: Option, +} + +/// The whole options payload of one `viewTransitionStart` call. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub(crate) struct VtOptions { + /// Seconds, like the `motion` prop. + duration: Option, + delay: Option, + ease: Option, + groups: HashMap, +} + +const DEFAULT_DURATION: f64 = 0.3; + +impl VtOptions { + pub(crate) fn parse(json: &str) -> Result { + let options: Self = serde_json::from_str(json).map_err(|error| error.to_string())?; + for (ease, duration, delay) in std::iter::once((&options.ease, options.duration, options.delay)) + .chain( + options + .groups + .values() + .map(|group| (&group.ease, group.duration, group.delay)), + ) + { + if let Some(ease) = ease { + motion::validate_ease(ease)?; + } + for (name, value) in [("duration", duration), ("delay", delay)] { + if let Some(value) = value { + if !value.is_finite() || value < 0.0 { + return Err(format!( + "view transition {name} must be a finite non-negative number" + )); + } + } + } + } + Ok(options) + } + + fn timing(&self, name: &str) -> (f64, f64, MotionEase) { + let group = self.groups.get(name); + let duration = group + .and_then(|group| group.duration) + .or(self.duration) + .unwrap_or(DEFAULT_DURATION); + let delay = group.and_then(|group| group.delay).or(self.delay).unwrap_or(0.0); + let ease = group + .and_then(|group| group.ease.clone()) + .or_else(|| self.ease.clone()) + .unwrap_or(MotionEase::Name("easeInOut".to_string())); + (duration, delay, ease) + } + + /// When the last group comes to rest, in seconds from the start. + fn longest_end(&self) -> f64 { + let default_end = + self.delay.unwrap_or(0.0) + self.duration.unwrap_or(DEFAULT_DURATION); + self.groups + .keys() + .map(|name| { + let (duration, delay, _) = self.timing(name); + delay + duration + }) + .fold(default_end, f64::max) + } +} + +// ── Capture ────────────────────────────────────────────────────────── + +/// One frozen named element: its cloned subtree and its painted place. +pub(crate) struct VtCapture { + pub(crate) tree: RetainedTree, + pub(crate) root: u64, + pub(crate) origin: Point, + pub(crate) size: Size, +} + +/// Clone every named element's subtree, with the bounds it painted at. +/// An element that never painted has no place to animate from, so it is +/// skipped and its name enters as a new element. +pub(crate) fn capture(tree: &RetainedTree) -> HashMap { + let bounds = crate::automation::all_bounds(); + let mut captures = HashMap::new(); + for (&id, element) in &tree.elements { + let Some(name) = element + .style + .as_deref() + .and_then(|style| style.view_transition_name.as_deref()) + else { + continue; + }; + if name.is_empty() || name == "none" { + continue; + } + let Some(rect) = bounds.get(&id) else { + continue; + }; + let mut frozen = RetainedTree::new(); + clone_subtree(tree, id, None, &mut frozen); + frozen.root_id = Some(id); + captures.insert( + name.to_string(), + VtCapture { + tree: frozen, + root: id, + origin: point(px(rect.x as f32), px(rect.y as f32)), + size: size(px(rect.width as f32), px(rect.height as f32)), + }, + ); + } + captures +} + +fn clone_subtree(source: &RetainedTree, id: u64, parent: Option, into: &mut RetainedTree) { + let Some(element) = source.elements.get(&id) else { + return; + }; + let mut clone = RetainedElement::new(id, element.element_type.clone(), element.subtree_revision); + clone.style = element.style.clone(); + clone.content = element.content.clone(); + clone.events = element.events.clone(); + clone.children = element.children.clone(); + clone.parent = parent; + clone.custom_props = element.custom_props.clone(); + // The copy is a still image. Without this, a fresh `MotionState` would + // replay the initial-to-animate run inside it. + clone.custom_props.remove("motion"); + // Locators must find the live element, never the copy. + clone.test_id = None; + clone.search_revision = element.search_revision; + let children = clone.children.clone(); + into.elements.insert(id, clone); + for child in children { + clone_subtree(source, child, Some(id), into); + } +} + +/// Fresh ids for clones whose original survives the swap. Far above what the +/// JS counter reaches, so the two ranges never meet. +const REMAP_BASE: u64 = 1 << 62; + +/// Give a clone a fresh id when its original is still in the live tree. +/// Building both under one id would hand them one GPUI element state. +/// A clone of a destroyed element keeps its id, and with it its scroll +/// offsets, which is the common pair case. +fn remap_live_ids(captures: &mut HashMap, live: &RetainedTree, next: &mut u64) { + for capture in captures.values_mut() { + let colliding: Vec = capture + .tree + .elements + .keys() + .copied() + .filter(|id| live.elements.contains_key(id)) + .collect(); + for from in colliding { + let to = *next; + *next += 1; + remap(&mut capture.tree, from, to); + if capture.root == from { + capture.root = to; + } + } + } +} + +fn remap(tree: &mut RetainedTree, from: u64, to: u64) { + let Some(mut element) = tree.elements.remove(&from) else { + return; + }; + element.id = to; + let parent = element.parent; + let children = element.children.clone(); + tree.elements.insert(to, element); + if let Some(parent) = parent.and_then(|id| tree.elements.get_mut(&id)) { + for child in &mut parent.children { + if *child == from { + *child = to; + } + } + } + for child in children { + if let Some(child) = tree.elements.get_mut(&child) { + child.parent = Some(to); + } + } + if tree.root_id == Some(from) { + tree.root_id = Some(to); + } +} + +// ── State ──────────────────────────────────────────────────────────── + +/// One running transition. The view holds at most one. A new start replaces +/// the one before it. +pub(crate) struct VtState { + captures: HashMap, + options: VtOptions, + started: Option, + frame_now: Option, + /// Every id inside a frozen tree. The view keeps the scroll handles and + /// custom element instances of these ids alive while the transition runs. + ids: HashSet, +} + +impl VtState { + pub(crate) fn new( + mut captures: HashMap, + options: VtOptions, + live: &RetainedTree, + ) -> Self { + let mut next = REMAP_BASE; + remap_live_ids(&mut captures, live, &mut next); + let ids = captures + .values() + .flat_map(|capture| capture.tree.elements.keys().copied()) + .collect(); + Self { + captures, + options, + started: None, + frame_now: None, + ids, + } + } + + /// Bring the clock up to this frame. Returns whether the transition still + /// runs. Called once per frame, before the tree builds. + pub(crate) fn tick(&mut self, now: Instant) -> bool { + let started = *self.started.get_or_insert(now); + self.frame_now = Some(now); + let elapsed = now.duration_since(started).as_secs_f64(); + elapsed < self.options.longest_end() + } + + /// Whether the view must keep per-id state alive for a frozen clone. + pub(crate) fn keeps(&self, id: u64) -> bool { + self.ids.contains(&id) + } + + fn capture(&self, name: &str) -> Option<&VtCapture> { + self.captures.get(name) + } + + /// This frame's animation values for one name, or `None` before the first + /// tick. + pub(crate) fn frame_for(&self, name: &str) -> Option { + let started = self.started?; + let now = self.frame_now?; + let (duration, delay, ease_spec) = self.options.timing(name); + let elapsed = now.duration_since(started).as_secs_f64(); + let raw = if duration <= 0.0 { + 1.0 + } else { + ((elapsed - delay) / duration).clamp(0.0, 1.0) + }; + let t = motion::ease(raw, &ease_spec); + + let group = self.options.groups.get(name); + // A group that names neither side crossfades, like the web default. + // A group that names a side animates only what that side says. + let explicit = group.is_some_and(|group| group.old.is_some() || group.new.is_some()); + let old = group.and_then(|group| group.old.clone()).unwrap_or_else(|| SideSpec { + opacity: (!explicit).then_some([1.0, 0.0]), + ..SideSpec::default() + }); + let new = group.and_then(|group| group.new.clone()).unwrap_or_else(|| SideSpec { + opacity: (!explicit).then_some([0.0, 1.0]), + ..SideSpec::default() + }); + let old_on_top = old.on_top.unwrap_or(false); + Some(VtElementFrame { + t, + old, + new, + old_on_top, + }) + } +} + +/// The values one named element animates with on one frame. +pub(crate) struct VtElementFrame { + t: f64, + old: SideSpec, + new: SideSpec, + old_on_top: bool, +} + +impl VtElementFrame { + /// The live element's opacity this frame, or `None` when it holds still. + pub(crate) fn new_opacity(&self) -> Option { + self.new + .opacity + .map(|[from, to]| motion::mix(from, to, self.t)) + } + + /// The live element's blur sigma this frame, or `None` when it holds + /// still. + pub(crate) fn new_blur(&self) -> Option { + self.new + .blur + .map(|[from, to]| motion::mix(from, to, self.t).max(0.0)) + } + + fn old_blur(&self) -> Option { + self.old + .blur + .map(|[from, to]| motion::mix(from, to, self.t).max(0.0)) + } + + /// How far past the group's bounds this frame's blur reaches: three + /// sigmas of the widest blur among the two sides. The group's mask + /// grows by this, so the halo paints instead of clipping at the edge. + fn mask_inflation(&self) -> Pixels { + let sigma = self + .new_blur() + .unwrap_or(0.0) + .max(self.old_blur().unwrap_or(0.0)); + px((3.0 * sigma).ceil() as f32) + } + + fn offset(x: Option<[VtLen; 2]>, y: Option<[VtLen; 2]>, t: f64, extent: Size) -> Point { + let resolve = |lens: Option<[VtLen; 2]>, extent: f32| { + lens.map_or(0.0, |[from, to]| { + motion::mix(from.resolve(extent as f64), to.resolve(extent as f64), t) + }) + }; + point( + px(resolve(x, f32::from(extent.width)) as f32), + px(resolve(y, f32::from(extent.height)) as f32), + ) + } + + fn new_offset(&self, extent: Size) -> Point { + Self::offset(self.new.translate_x, self.new.translate_y, self.t, extent) + } + + fn old_offset(&self, extent: Size) -> Point { + Self::offset(self.old.translate_x, self.old.translate_y, self.t, extent) + } + + fn old_opacity(&self) -> f64 { + self.old + .opacity + .map_or(1.0, |[from, to]| motion::mix(from, to, self.t)) + } +} + +// ── The transition element ─────────────────────────────────────────── + +/// Wrap one named live element for this frame of the transition. +pub(super) fn wrap( + element: &RetainedElement, + built: AnyElement, + frame: VtElementFrame, + ctx: &mut BuildCtx, + window: &mut Window, + cx: &mut gpui::Context, +) -> AnyElement { + use gpui::prelude::*; + + let name = element + .style + .as_deref() + .and_then(|style| style.view_transition_name.as_deref()) + .unwrap_or_default(); + let vt = ctx.vt; + let old = vt.and_then(|vt| vt.capture(name)).map(|capture| OldCopy { + element: build_frozen(capture, &frame, ctx, window, cx), + layout: IsolatedLayout::new(), + origin: capture.origin + frame.old_offset(capture.size), + size: capture.size, + }); + VtGroup { + child: built, + old, + frame, + } + .into_any_element() +} + +/// Build the frozen copy of one capture, held at its captured size and faded +/// and blurred for this frame. +fn build_frozen( + capture: &VtCapture, + frame: &VtElementFrame, + ctx: &mut BuildCtx, + window: &mut Window, + cx: &mut gpui::Context, +) -> AnyElement { + use gpui::prelude::*; + + // The frozen tree builds through the same walk as the live one. The + // nested context clears `vt`, so a name inside the copy never starts + // a transition of its own. + let mut frozen_ctx = BuildCtx { + tree: &capture.tree, + event_callback: ctx.event_callback, + focus_handles: ctx.focus_handles, + scroll_handles: &mut *ctx.scroll_handles, + custom_registry: &mut *ctx.custom_registry, + virtual_lists: &mut *ctx.virtual_lists, + motion_states: &mut *ctx.motion_states, + scrollbars: &mut *ctx.scrollbars, + now: ctx.now, + motion_active: &mut *ctx.motion_active, + selection: ctx.selection.clone(), + cascade: ctx.cascade.clone(), + highlight: None, + highlights: &mut *ctx.highlights, + highlight_events: &mut *ctx.highlight_events, + vt: None, + frozen: true, + direct_rules: Vec::new(), + descendant_rules: Vec::new(), + }; + // A frozen copy builds outside the tree walk, so it has no child position + // and the index states do not apply to it. + let content = build_element(capture.root, None, &mut frozen_ctx, window, cx); + // The shell fixes the copy at its captured size and carries this + // frame's opacity down the whole copy. + let mut shell = gpui::div() + .w(capture.size.width) + .h(capture.size.height) + .overflow_hidden(); + shell.style().opacity = Some(frame.old_opacity() as f32); + let shell = shell.child(content); + // The blur rides a wrapper that does not clip. On the shell itself, + // its `overflow: hidden` would clip the halo at the captured edge. + match frame.old_blur() { + Some(blur) => gpui::div() + .w(capture.size.width) + .h(capture.size.height) + .blur(px(blur as f32)) + .child(shell) + .into_any_element(), + None => shell.into_any_element(), + } +} + +/// Build a frozen copy for every captured name that has no live element this +/// frame. The renderer appends these to the root wrapper, so they paint over +/// the tree at their captured place while the group's `old` side fades or +/// moves them out. +pub(super) fn exit_copies( + ctx: &mut BuildCtx, + window: &mut Window, + cx: &mut gpui::Context, +) -> Vec { + use gpui::prelude::*; + + let Some(vt) = ctx.vt else { + return Vec::new(); + }; + let live: HashSet<&str> = ctx + .tree + .elements + .values() + .filter_map(|element| element.style.as_deref()?.view_transition_name.as_deref()) + .collect(); + // Sorted, so two exit copies paint in the same order on every frame. + let mut names: Vec<&String> = vt + .captures + .keys() + .filter(|name| !live.contains(name.as_str())) + .collect(); + names.sort(); + let mut copies = Vec::new(); + for name in names { + let Some(capture) = vt.capture(name) else { + continue; + }; + let Some(frame) = vt.frame_for(name) else { + continue; + }; + copies.push( + ExitCopy { + element: build_frozen(capture, &frame, ctx, window, cx), + layout: IsolatedLayout::new(), + origin: capture.origin + frame.old_offset(capture.size), + size: capture.size, + } + .into_any_element(), + ); + } + copies +} + +/// The frozen copy of one name, ready to paint at its captured place. +struct OldCopy { + element: AnyElement, + /// The copy lays out here rather than in the window's tree, because its + /// captured size is fixed and must not join the live layout. + layout: IsolatedLayout, + origin: Point, + size: Size, +} + +/// One named element while the transition runs. +/// +/// The group hands the live child's layout through untouched, so the page +/// around a transition lays out exactly as it will at rest. Movement happens +/// at paint: the child prepaints under an element offset, and the frozen copy +/// prepaints at its captured bounds. Both paint inside the group's bounds as +/// a mask, so a slide stays inside the element's own area. When a side blurs, +/// the mask grows by the blur's support, so the halo shows instead of +/// clipping at the edge. +struct VtGroup { + child: AnyElement, + old: Option, + frame: VtElementFrame, +} + +impl Element for VtGroup { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + (self.child.request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let offset = self.frame.new_offset(bounds.size); + let mask = bounds.dilate(self.frame.mask_inflation()); + window.with_content_mask(Some(ContentMask { bounds: mask }), |window| { + if let Some(old) = &mut self.old { + let element = &mut old.element; + let origin = old.origin; + let extent = old.size; + old.layout.enter(window, |window| { + element.layout_as_root( + size( + AvailableSpace::Definite(extent.width), + AvailableSpace::Definite(extent.height), + ), + window, + cx, + ); + element.prepaint_at(origin, window, cx); + }); + } + window.with_element_offset(offset, |window| { + self.child.prepaint(window, cx); + }); + }); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut (), + _prepaint: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let mask = bounds.dilate(self.frame.mask_inflation()); + window.with_content_mask(Some(ContentMask { bounds: mask }), |window| { + let paint_old = |old: &mut Option, window: &mut Window, cx: &mut App| { + if let Some(old) = old { + let element = &mut old.element; + old.layout.enter(window, |window| element.paint(window, cx)); + } + }; + if self.frame.old_on_top { + self.child.paint(window, cx); + paint_old(&mut self.old, window, cx); + } else { + paint_old(&mut self.old, window, cx); + self.child.paint(window, cx); + } + }); + } +} + +impl IntoElement for VtGroup { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +/// One exit-only frozen copy, painted over the tree at its captured place. +/// +/// The element asks for no layout space of its own. The copy lays out in its +/// own isolated tree at its captured size, so the live layout never sees it. +struct ExitCopy { + element: AnyElement, + layout: IsolatedLayout, + origin: Point, + size: Size, +} + +impl Element for ExitCopy { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + ( + window.request_layout(gpui::Style::default(), None::, cx), + (), + ) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let element = &mut self.element; + let origin = self.origin; + let extent = self.size; + self.layout.enter(window, |window| { + element.layout_as_root( + size( + AvailableSpace::Definite(extent.width), + AvailableSpace::Definite(extent.height), + ), + window, + cx, + ); + element.prepaint_at(origin, window, cx); + }); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut (), + _prepaint: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let element = &mut self.element; + self.layout.enter(window, |window| element.paint(window, cx)); + } +} + +impl IntoElement for ExitCopy { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + /// A state with no captures, halted at `elapsed_ms` into the transition. + fn state_at(options: &str, elapsed_ms: u64) -> VtState { + let options = VtOptions::parse(options).unwrap(); + let started = Instant::now(); + VtState { + captures: HashMap::new(), + options, + started: Some(started), + frame_now: Some(started + Duration::from_millis(elapsed_ms)), + ids: HashSet::new(), + } + } + + #[test] + fn blur_mixes_on_both_sides() { + let state = state_at( + r#"{"groups":{"screen":{"duration":0.3,"ease":"linear","old":{"blur":[0,6]},"new":{"blur":[6,0],"opacity":[0,1]}}}}"#, + 150, + ); + let frame = state.frame_for("screen").unwrap(); + assert_eq!(frame.new_blur(), Some(3.0)); + assert_eq!(frame.old_blur(), Some(3.0)); + assert_eq!(frame.new_opacity(), Some(0.5)); + } + + #[test] + fn a_side_without_blur_holds_still() { + let state = state_at(r#"{"groups":{"screen":{"old":{"opacity":[1,0]}}}}"#, 150); + let frame = state.frame_for("screen").unwrap(); + assert_eq!(frame.new_blur(), None); + assert_eq!(frame.old_blur(), None); + } +} diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index 223dfdc4..7d76d743 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -507,6 +507,10 @@ style_desc! { scroll_padding_bottom: Option = "scrollPaddingBottom", scroll_padding_left: Option = "scrollPaddingLeft", + // View transitions. The name pairs the element that leaves with the + // element that arrives across one `startViewTransition` call. + view_transition_name: Option = "viewTransitionName", + // Cursor cursor: Option = "cursor", /// `"auto"` blocks mouse hits behind this element. `"none"` never does. diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index 4e6fb0f3..ebb2c88b 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -313,6 +313,9 @@ pub(crate) fn apply_motion( if let Some(opacity) = motion.opacity { el = el.opacity(opacity as f32); } + if let Some(blur) = motion.blur { + el = el.blur(gpui::px(blur as f32)); + } el } diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs index 6d176e76..763fb480 100644 --- a/packages/native/src/test_renderer.rs +++ b/packages/native/src/test_renderer.rs @@ -747,6 +747,36 @@ impl TestGpuixRenderer { }) } + /// Clone every element that has a `viewTransitionName`, with its painted + /// bounds. Call flush() first, so the bounds are current. + #[napi] + pub fn view_transition_capture(&self) -> Result<()> { + with_test_state(|cx, window, view| { + let view = view.clone(); + cx.update_window(window, |_, _window, app| { + view.update(app, |view, _cx| view.view_transition_capture()); + }) + .map_err(|e| Error::from_reason(e.to_string()))?; + Ok(()) + }) + } + + /// Animate every captured name toward its new element. Call flush() + /// after, and move the automation clock to step through the frames. + #[napi] + pub fn view_transition_start(&self, options: Option) -> Result<()> { + let options = options.unwrap_or_else(|| "{}".to_string()); + with_test_state(|cx, window, view| { + let view = view.clone(); + let result = cx + .update_window(window, |_, _window, app| { + view.update(app, |view, _cx| view.view_transition_start(&options)) + }) + .map_err(|e| Error::from_reason(e.to_string()))?; + result.map_err(Error::from_reason) + }) + } + /// Scroll a child into view by its index in the children list. /// Call flush() after to apply and re-render. For a `` the /// scroll is queued and applied on that flush, after the child splice. diff --git a/packages/react/src/__tests__/view-transitions.test.tsx b/packages/react/src/__tests__/view-transitions.test.tsx new file mode 100644 index 00000000..30b04aa0 --- /dev/null +++ b/packages/react/src/__tests__/view-transitions.test.tsx @@ -0,0 +1,196 @@ +/** + * View transitions. The automation clock is paused, so every frame of the + * animation is read at an exact time. Bounds come from the paint trackers, + * which record where an element really painted, moved or not. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import React from "react" +import { createTestRoot, hasNativeTestRenderer, type TestRoot } from "../testing" +import { startViewTransition, type ViewTransitionOptions } from "../view-transitions" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +/** The `key` makes each screen its own element, the way a navigation swaps + * one screen component for another. Without it, React updates one element + * in place, which is the separate case the last test covers. */ +function Screen({ label, color }: { label: string; color: string }) { + return ( +
+ {label} +
+ ) +} + +/** The iOS push: the new screen slides in from the right over the old one, + * and the old one slides a third of the way out to the left. */ +const PUSH: ViewTransitionOptions = { + groups: { + screen: { + duration: 0.3, + ease: "linear", + old: { translateX: ["0%", "-30%"] }, + new: { translateX: ["100%", "0%"] }, + }, + }, +} + +describeNative("view transitions", () => { + let root: TestRoot + beforeEach(() => { + root = createTestRoot() + }) + afterEach(() => { + root.unmount() + }) + + const screenId = () => root.renderer.findByType("div")[0]!.id + const boundsOf = (id: number) => root.renderer.getElementBounds(id) + + it("slides the pair like an iOS push", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const oldId = screenId() + const baseX = boundsOf(oldId)![0] + + startViewTransition(renderer, () => render(), PUSH) + const newId = screenId() + expect(newId).not.toBe(oldId) + + // At the start, the new screen sits one width to the right, and the + // frozen copy of the old one still paints at its place. + expect(boundsOf(newId)![0]).toBeCloseTo(baseX + 300, 0) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0) + + // Halfway, with a linear ease: the new screen covered half its way in, + // and the old copy moved 15% of its width out. + renderer.clockFastForward(150) + expect(boundsOf(newId)![0]).toBeCloseTo(baseX + 150, 0) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX - 45, 0) + + // Past the end: the new screen rests at its layout place, and the copy + // paints no more. + renderer.clockFastForward(400) + expect(boundsOf(newId)![0]).toBeCloseTo(baseX, 0) + expect(boundsOf(oldId)).toBeNull() + }) + + it("crossfades by default without moving anything", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const oldId = screenId() + const baseX = boundsOf(oldId)![0] + + startViewTransition(renderer, () => render()) + const newId = screenId() + + renderer.clockFastForward(150) + expect(boundsOf(newId)![0]).toBeCloseTo(baseX, 0) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0) + + renderer.clockFastForward(400) + expect(boundsOf(oldId)).toBeNull() + }) + + it("animates a name that enters without a captured pair", () => { + const { render, renderer } = root + renderer.clockPause() + render(
) + + startViewTransition( + renderer, + () => render(), + { groups: { screen: { duration: 0.3, ease: "linear", new: { translateY: ["100%", "0%"] } } } } + ) + const id = screenId() + const baseY = 200 * 1.0 + + // The screen is 200 high, so it starts one height down and slides up. + expect(boundsOf(id)![1]).toBeCloseTo(baseY, 0) + renderer.clockFastForward(150) + expect(boundsOf(id)![1]).toBeCloseTo(baseY / 2, 0) + renderer.clockFastForward(400) + expect(boundsOf(id)![1]).toBeCloseTo(0, 0) + }) + + it("paints an exit copy for a name that leaves without a successor", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const oldId = screenId() + const baseX = boundsOf(oldId)![0] + + // The next tree has no element with the name. The frozen copy paints + // over the tree and the `old` side slides and blurs it out. + startViewTransition(renderer, () => render(
), { + groups: { + screen: { + duration: 0.3, + ease: "linear", + old: { translateX: ["0%", "100%"], opacity: [1, 0], blur: [0, 6] }, + }, + }, + }) + + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0) + renderer.clockFastForward(150) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX + 150, 0) + renderer.clockFastForward(400) + expect(boundsOf(oldId)).toBeNull() + }) + + it("a fresh start replaces a running transition", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const baseX = boundsOf(screenId())![0] + + startViewTransition(renderer, () => render(), PUSH) + renderer.clockFastForward(150) + + // Start again mid-flight. The second transition captures the moved pair + // and runs on its own clock from here. + startViewTransition(renderer, () => render(), PUSH) + const thirdId = screenId() + expect(boundsOf(thirdId)![0]).toBeCloseTo(baseX + 300, 0) + renderer.clockFastForward(500) + expect(boundsOf(thirdId)![0]).toBeCloseTo(baseX, 0) + }) + + it("transitions an element React updates in place", () => { + const { render, renderer } = root + renderer.clockPause() + // No keys: React keeps the element and only swaps its style. The frozen + // copy takes a fresh id, so the live element and the copy never share + // GPUI element state. + render(
) + const id = screenId() + + startViewTransition(renderer, () => + render( +
+ ) + ) + expect(screenId()).toBe(id) + renderer.clockFastForward(500) + expect(boundsOf(id)![3]).toBeCloseTo(100, 0) + }) + + it("runs the update alone on a renderer without the native methods", () => { + let ran = false + const bare = {} as Parameters[0] + startViewTransition(bare, () => { + ran = true + }) + expect(ran).toBe(true) + }) +}) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b9a008a5..9c4c7514 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -9,6 +9,13 @@ export { startFrameLoop, } from "./reconciler/renderer.js" export { GpuixContext, useGpuix, useGpuixRequired } from "./hooks/use-gpuix.js" +export { startViewTransition } from "./view-transitions.js" +export type { + ViewTransitionGroupOptions, + ViewTransitionLength, + ViewTransitionOptions, + ViewTransitionSide, +} from "./view-transitions.js" export { useWindowInsets, useWindowSize } from "./hooks/use-window-size.js" export { findRanges, useTextSearch } from "./hooks/use-text-search.js" export type { diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index 51215786..fa36472b 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -68,6 +68,8 @@ interface NativeTestRendererApi extends NativeRenderer { scrollToItem(elementId: number, index: number, offsetInItem?: number): void scrollIntoView(elementId: number, block?: string, inline?: string): void getScrollOffset(elementId: number): number[] | null + viewTransitionCapture(): void + viewTransitionStart(options?: string): void getListScrollTop(elementId: number): number[] | null setDebugFrameOverlay(mode: DebugFrameOverlayMode): string getDebugFrameOverlay(): string @@ -583,6 +585,22 @@ export class TestRenderer implements NativeRenderer { return [result[0], result[1], result[2]] } + // ── View transitions ──────────────────────────────────────────── + + /** Clone every element that has a `viewTransitionName`, with its painted + * bounds. The flush first makes those bounds current. */ + viewTransitionCapture(): void { + this.native.flush() + this.native.viewTransitionCapture() + } + + /** Animate every captured name toward its new element. Pause the clock + * first and move it to step through the frames. */ + viewTransitionStart(options?: string): void { + this.native.viewTransitionStart(options) + this.native.flush() + } + // ── Selection API ─────────────────────────────────────────────── /** Drag-select from (x1,y1) to (x2,y2) and return the selected text. diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index d662d0dd..0cee5ca4 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -26,6 +26,8 @@ export interface MotionStyle { */ height?: number | "auto" opacity?: Numeric + /** A `filter: blur()` sigma in pixels, on the element and its children. */ + blur?: Numeric top?: Numeric right?: Numeric bottom?: Numeric @@ -305,6 +307,9 @@ export interface StyleDesc { scrollPaddingRight?: Numeric scrollPaddingBottom?: Numeric scrollPaddingLeft?: Numeric + /** The name that pairs this element across a `startViewTransition` call: + * the old element with this name animates into the new one. */ + viewTransitionName?: string /** `auto`, `contain` or `none`, one word for both axes or two with the x * axis first. A scroll box keeps a wheel event it can scroll with. At its * end, `auto` hands the event to the nearest scroll box around it and @@ -810,6 +815,15 @@ export interface NativeRenderer { * else. `itemIndex == item count` is gpui's at-end sentinel. */ getListScrollTop?(elementId: number): Array | null + // ── View transitions ─────────────────────────────────────────── + /** Clone every element that has a `viewTransitionName`, with its painted + * bounds. Call before the update, then `viewTransitionStart` after it. + * `startViewTransition` does both. */ + viewTransitionCapture?(): void + /** Animate every captured name toward its new element. `options` is the + * JSON of a `ViewTransitionOptions` value, or nothing for a crossfade. */ + viewTransitionStart?(options?: string): void + // ── Selection API ────────────────────────────────────────────── /** The current text selection joined in document order, or null. */ getSelectedText?(): string | null diff --git a/packages/react/src/view-transitions.ts b/packages/react/src/view-transitions.ts new file mode 100644 index 00000000..a098f342 --- /dev/null +++ b/packages/react/src/view-transitions.ts @@ -0,0 +1,68 @@ +// The View Transitions API: capture the named elements, apply the React +// update synchronously, then animate each name from its old place to its +// new one. The native renderer owns the animation, so React renders once. + +import { flushSync } from "./reconciler/reconciler.js" +import type { MotionEase, NativeRenderer } from "./types/host.js" + +/** A translation distance: pixels as a number or "Npx", or a share of the + * element's size as "N%". */ +export type ViewTransitionLength = number | string + +/** What one side of a pair does over the transition. Every field is a + * `[from, to]` pair. A missing field holds still. */ +export interface ViewTransitionSide { + translateX?: [ViewTransitionLength, ViewTransitionLength] + translateY?: [ViewTransitionLength, ViewTransitionLength] + opacity?: [number, number] + /** A `filter: blur()` sigma in pixels. */ + blur?: [number, number] + /** Paint this side over the other one. Only read on `old`. */ + onTop?: boolean +} + +export interface ViewTransitionGroupOptions { + /** Seconds, like the `motion` prop. The default is 0.3. */ + duration?: number + /** Seconds before this group starts. */ + delay?: number + ease?: MotionEase + /** The element that leaves. When a group gives neither `old` nor `new`, + * the pair crossfades. */ + old?: ViewTransitionSide + /** The element that arrives. */ + new?: ViewTransitionSide +} + +export interface ViewTransitionOptions { + /** Seconds, for every group that does not set its own. */ + duration?: number + delay?: number + ease?: MotionEase + /** Options per `viewTransitionName`. A name with no entry crossfades. */ + groups?: Record +} + +/** + * Run `update` and animate every element that carries a + * `viewTransitionName` from its place before the update to its place after + * it. Give the leaving screen and the arriving screen the same name to + * animate a navigation as a pair. A name that only leaves paints a frozen + * copy over the tree while its group's `old` side runs, without the clip + * of its former ancestors. + * + * On a renderer without the native methods, this runs `update` alone. + */ +export function startViewTransition( + renderer: NativeRenderer, + update: () => void, + options?: ViewTransitionOptions, +): void { + if (!renderer.viewTransitionCapture || !renderer.viewTransitionStart) { + update() + return + } + renderer.viewTransitionCapture() + flushSync(update) + renderer.viewTransitionStart(JSON.stringify(options ?? {})) +}