Skip to main content

wisp_storybook/stories/
s_zoom_pushin.rs

1//! Story: editor zoom push-in, animated (ED.16 / M-EDIT).
2//!
3//! The "rostrum push-in" — the single gesture that reads as *cinematic* in a
4//! screen recording. A mock app card stands in for the recorded screen; the
5//! camera eases in toward a focal button, holds on it, then eases back out —
6//! the three-phase profile [`edit::zoom_anim::zoom_at`] computes for real.
7//! Here it is reproduced inline (an eased ramp-in → hold → ramp-out) so the
8//! story stays a pure `wisp` demo, scaling the content node about the focal
9//! point exactly as `EditorPreview::render_framed` pins it in the editor:
10//! `position = focal · (1 − z)` keeps the target glued in place while only the
11//! scale animates.
12//!
13//! Single-bind-group graphics (no blur) → runs on every CI OS; NOT in
14//! `LAVAPIPE_INCOMPATIBLE`. Animated (`tick: Some`) → exported to MP4 by
15//! `wisp-export-animated` for the ED.16 chapter.
16
17use glam::Vec2;
18use wisp::application::Application;
19use wisp::math::Rect;
20use wisp::{Color, Fill, Graphics, Node, Stage};
21
22use crate::story::Story;
23
24/// Focal point of the push-in, in NDC — the centre of the accent button the
25/// camera tightens toward. Shared by `build` (where the button is drawn) and
26/// `tick` (where the zoom is pinned).
27const FOCAL: Vec2 = Vec2::new(0.40, -0.30);
28/// Peak zoom factor at the hold.
29const AMOUNT: f32 = 2.4;
30
31pub fn story() -> Story {
32    Story {
33        id: "editor-zoom-pushin",
34        category: "Editor",
35        title: "Zoom push-in — the rostrum move",
36        milestone: "ED.16",
37        writeup: include_str!("writeups/editor_zoom.md"),
38        build,
39        tick: Some(tick),
40    }
41}
42
43fn build(_app: &Application, stage: &mut Stage) {
44    let mut g = Graphics::new();
45
46    // Desktop backdrop — a full-NDC cool gradient. Because the content scales
47    // about the focal point at z ≥ 1, this always covers the frame (no black
48    // edges creep in as the camera pushes in).
49    g.fill(Fill::LinearGradient {
50        start: Vec2::new(0.0, 1.0),
51        end: Vec2::new(0.0, -1.0),
52        color_a: Color::rgb_u8(70, 82, 104),
53        color_b: Color::rgb_u8(40, 47, 62),
54    });
55    g.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
56
57    // App window card.
58    g.fill(Fill::Solid(Color::rgb_u8(248, 249, 251)));
59    g.draw_rounded_rect(Rect::new(-0.82, -0.72, 1.64, 1.44), 0.07);
60    // Title bar.
61    g.fill(Fill::Solid(Color::rgb_u8(228, 232, 238)));
62    g.draw_rect(Rect::new(-0.80, 0.52, 1.60, 0.18));
63    // Traffic-light dots.
64    for (x, c) in [
65        (-0.720, Color::rgb_u8(237, 106, 94)),
66        (-0.645, Color::rgb_u8(244, 191, 79)),
67        (-0.570, Color::rgb_u8(98, 197, 84)),
68    ] {
69        g.fill(Fill::Solid(c));
70        g.draw_ellipse(Vec2::new(x, 0.61), Vec2::splat(0.022));
71    }
72
73    // Heading + placeholder text rows.
74    g.fill(Fill::Solid(Color::rgb_u8(78, 88, 104)));
75    g.draw_rounded_rect(Rect::new(-0.66, 0.34, 0.62, 0.09), 0.03);
76    g.fill(Fill::Solid(Color::rgb_u8(199, 205, 214)));
77    for (y, w) in [(0.20, 1.18), (0.07, 0.96), (-0.06, 1.10)] {
78        g.draw_rounded_rect(Rect::new(-0.66, y, w, 0.055), 0.025);
79    }
80
81    // The focal accent button — the thing the camera pushes into. Drawn last
82    // so it sits above the card.
83    let bw = 0.34;
84    let bh = 0.15;
85    g.fill(Fill::Solid(Color::rgb_u8(56, 120, 242)));
86    g.draw_rounded_rect(
87        Rect::new(FOCAL.x - bw / 2.0, FOCAL.y - bh / 2.0, bw, bh),
88        0.045,
89    );
90    // Button label bar (a light pill inside the button).
91    g.fill(Fill::Solid(Color::rgb_u8(232, 240, 255)));
92    g.draw_rounded_rect(
93        Rect::new(FOCAL.x - 0.10, FOCAL.y - 0.022, 0.20, 0.044),
94        0.022,
95    );
96
97    let _ = stage.add_child(stage.root(), g);
98}
99
100/// Eased zoom factor at story time `t` (seconds, `0..3`): a cubic ramp-in to
101/// [`AMOUNT`], a flat hold, and a symmetric ramp-out — the same three-phase
102/// shape `edit::zoom_anim::zoom_at` produces, reproduced here without the dep.
103fn zoom_scale(t: f32) -> f32 {
104    // In-out cubic, pinned f(0)=0, f(1)=1.
105    let ease = |p: f32| {
106        if p < 0.5 {
107            4.0 * p * p * p
108        } else {
109            1.0 - (-2.0 * p + 2.0).powi(3) / 2.0
110        }
111    };
112    if t < 0.9 {
113        1.0 + (AMOUNT - 1.0) * ease((t / 0.9).clamp(0.0, 1.0))
114    } else if t < 2.1 {
115        AMOUNT
116    } else {
117        AMOUNT - (AMOUNT - 1.0) * ease(((t - 2.1) / 0.9).clamp(0.0, 1.0))
118    }
119}
120
121fn tick(stage: &mut Stage, t: f32) {
122    let z = zoom_scale(t);
123    let child = stage
124        .get(stage.root())
125        .and_then(|n| n.container().children().next());
126    if let Some(id) = child
127        && let Some(Node::Graphics(g)) = stage.get_mut(id)
128    {
129        // Scale about the focal point: world = position + scale·local, so
130        // pinning `FOCAL` in place needs position = FOCAL·(1 − z). Identical
131        // to the editor's `render_framed` focal-pin math.
132        g.container.transform.scale = Vec2::splat(z);
133        g.container.transform.position = FOCAL * (1.0 - z);
134    }
135}