summaryrefslogtreecommitdiff
path: root/src/animation.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 23:34:20 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitfaa5ee1d81e2b9c4be6925b7178db2d2dc4577f8 (patch)
tree14799bbd5748743b985ac617ba134ad12bdc9269 /src/animation.zig
parentd8d770454e77fbeeb0dd9c5e965a7eda6a5558ba (diff)
downloadpardes-faa5ee1d81e2b9c4be6925b7178db2d2dc4577f8.tar.gz
pardes-faa5ee1d81e2b9c4be6925b7178db2d2dc4577f8.zip
G3: the focused cursor glides a jump on the Motion flavour's springs, and blinks
The core owns the motion, as Lift: animation.CursorGlide keeps the focused cursor's four corners on springs (leading corners at the flavour's pace x2.4, trailing ones x follow, so a jump smears and collapses; a long jump bows by the flavour's arc), sampled at each frame's own time. A step of a cell or less, insert mode, a focus change and any scroll (a file's view, a terminal's output) land at once; a snap lands a glide under way too. The quad is clamped to the focused pane when drawn (never its springs). Chrome carries it on the wire (cursor_glide: bit 1 a cursor, bit 0 gliding; cursor_alpha; cursor_quad, grid cells; cursor_idle_ms): the GUI draws the quad as an overlay in the cursor's colour, at 85% (70% on the long flavours) rising to solid as it lands, and hides the cells' cursors while it does: one or the other every frame. Only the focused cursor glides. Blink (CursorBlink, on by default in pixel shells) is the shell's: from cursor_idle_ms and when the frame arrived, solid while typing and 500 ms after, 530 ms halves with 80 ms eased edges, solid after 10 s idle, and never while the window is unfocused or minimized or on a virtual clock. An edge redraws the last frame with no core render (level B, blink_redraws); an attached GUI blinks from the same field without a frame per edge. Tests: snaps a cell, glides a jump with smear and bow, arrives under a pixel by 150 ms on smooth and lands exactly, shifts with a scroll; blink holds, eases, stops; the core glides a jump (ge) frame by frame (gliding exactly while it moves, alpha rising, exact at rest), snaps a step (j) and a switch to insert mid-glide; a terminal's scrolling output lands its cursor; wire round-trip of the cursor record. Shared files: src/animation.zig, src/pardes.zig, src/config.zig (toggle CursorBlink), src/surface.zig, src/detached/wire.zig.
Diffstat (limited to 'src/animation.zig')
-rw-r--r--src/animation.zig201
1 files changed, 201 insertions, 0 deletions
diff --git a/src/animation.zig b/src/animation.zig
index 9b43644e..9002a2c6 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -870,6 +870,207 @@ test "a flavour's stretched pane lands exactly on its box, and a click finds the
}
}
+/// The focused cursor's quad (docs/render-pipeline.md §5.4), in grid cells:
+/// four corners, each on its own springs, so a jump smears along its path
+/// (the leading corners stiffer than the trailing ones, Motion.follow) and
+/// collapses at rest; a long jump bows (Motion.arc). A move of a cell or
+/// less, or any the caller calls a snap, lands at once.
+pub const CursorGlide = struct {
+ /// Top left, top right, bottom right, bottom left; x then y.
+ corners: [4][2]Spring = @splat(@splat(.{})),
+ target: Box = .{},
+ from_centre: [2]f32 = .{ 0, 0 },
+ placed: bool = false,
+
+ /// How much faster than the flavour's own pace a cursor moves: it
+ /// follows input (§8.1: settles in 90-150 ms on the default).
+ pub const pace: f32 = 2.4;
+ /// A jump longer than this, in cells, bows.
+ pub const arc_from: f32 = 8;
+
+ fn cornersOf(box: Box) [4][2]f32 {
+ return .{ .{ box.x, box.y }, .{ box.x + box.w, box.y }, .{ box.x + box.w, box.y + box.h }, .{ box.x, box.y + box.h } };
+ }
+
+ fn centre(box: Box) [2]f32 {
+ return .{ box.x + box.w / 2, box.y + box.h / 2 };
+ }
+
+ /// Heads for `box` at `now_ns`.
+ pub fn aim(glide: *CursorGlide, box: Box, now_ns: u64, motion: Motion, snap: bool) void {
+ // The same mark: nothing to do, unless a snap lands a glide under way.
+ if (glide.placed and std.meta.eql(box, glide.target) and !(snap and glide.moving())) return;
+ const to = cornersOf(box);
+ const from_c = if (glide.placed) glide.centreAt(now_ns) else centre(box);
+ const to_c = centre(box);
+ const dx = to_c[0] - from_c[0];
+ const dy = to_c[1] - from_c[1];
+ const jump = @abs(dx) > 1.01 or @abs(dy) > 1.01;
+ const land = !glide.placed or snap or motion.instant or !jump;
+ glide.from_centre = from_c;
+ glide.target = box;
+ glide.placed = true;
+ for (&glide.corners, to) |*corner, point| {
+ // Leading: the corner ahead of the move, on the side it goes.
+ const ahead = (point[0] - to_c[0]) * dx + (point[1] - to_c[1]) * dy;
+ var own = motion;
+ own.omega *= pace * (if (ahead > 0) 1 else motion.follow);
+ if (land) {
+ for (corner, point) |*axis, value| axis.* = .{ .omega = own.omega, .zeta = own.zeta, .target = value, .from = value };
+ continue;
+ }
+ for (corner, point) |*axis, value| axis.retarget(value, now_ns, own);
+ }
+ }
+
+ fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 {
+ var c: [2]f32 = .{ 0, 0 };
+ for (glide.corners) |corner| for (&c, corner) |*sum, axis| {
+ sum.* += axis.value(now_ns) / 4;
+ };
+ return c;
+ }
+
+ /// Text under the cursor moved by (dx, dy) cells (a scroll): the quad
+ /// moves with it, rather than gliding against text that jumped.
+ pub fn shift(glide: *CursorGlide, dx: f32, dy: f32) void {
+ for (&glide.corners) |*corner| for (corner, [2]f32{ dx, dy }) |*axis, d| {
+ axis.from += d;
+ axis.target += d;
+ };
+ glide.from_centre = .{ glide.from_centre[0] + dx, glide.from_centre[1] + dy };
+ glide.target.x += dx;
+ glide.target.y += dy;
+ }
+
+ /// The quad at `now_ns`, bowed on a long jump by the flavour's arc.
+ pub fn sample(glide: *const CursorGlide, now_ns: u64, motion: Motion) [4][2]f32 {
+ var out: [4][2]f32 = undefined;
+ for (&out, glide.corners) |*point, corner| for (point, corner) |*value, axis| {
+ value.* = axis.value(now_ns);
+ };
+ const to_c = centre(glide.target);
+ const dx = to_c[0] - glide.from_centre[0];
+ const dy = to_c[1] - glide.from_centre[1];
+ const length = @sqrt(dx * dx + dy * dy);
+ if (motion.arc == 0 or length < arc_from) return out;
+ const now_c = glide.centreAt(now_ns);
+ const left = @sqrt((to_c[0] - now_c[0]) * (to_c[0] - now_c[0]) + (to_c[1] - now_c[1]) * (to_c[1] - now_c[1]));
+ const travelled = std.math.clamp(1 - left / length, 0, 1);
+ const bow = motion.arc * length * 4 * travelled * (1 - travelled);
+ // Perpendicular to the path, to its left.
+ for (&out) |*point| {
+ point[0] += -dy / length * bow;
+ point[1] += dx / length * bow;
+ }
+ return out;
+ }
+
+ /// Steps the springs to `now_ns`; true while the quad still moves.
+ pub fn step(glide: *CursorGlide, now_ns: u64) bool {
+ var any = false;
+ for (&glide.corners) |*corner| for (corner) |*axis| {
+ if (axis.step(now_ns)) any = true;
+ };
+ return any;
+ }
+
+ /// How far, in cells, the furthest corner still is from its mark.
+ pub fn remaining(glide: *const CursorGlide, now_ns: u64) f32 {
+ var most: f32 = 0;
+ for (glide.corners) |corner| for (corner) |axis| {
+ most = @max(most, @abs(axis.value(now_ns) - axis.target));
+ };
+ return most;
+ }
+
+ pub fn moving(glide: *const CursorGlide) bool {
+ for (glide.corners) |corner| for (corner) |axis| {
+ if (!axis.settled) return true;
+ };
+ return false;
+ }
+};
+
+/// The cursor's opacity under blink (§5.4): solid while typing and for
+/// `hold_ns` after, then on and off with an eased edge, solid again after
+/// `idle_ns` of no input. `edge` says whether it is mid-edge (a frame is
+/// owed), `next_ns` when the next edge starts (a wake).
+pub const Blink = struct {
+ pub const hold_ns: u64 = 500 * std.time.ns_per_ms;
+ pub const idle_ns: u64 = 10 * std.time.ns_per_s;
+ pub const half_ns: u64 = 530 * std.time.ns_per_ms;
+ pub const edge_ns: u64 = 80 * std.time.ns_per_ms;
+
+ alpha: f32,
+ edge: bool,
+ next_ns: ?u64,
+
+ pub fn at(now_ns: u64, input_ns: u64) Blink {
+ const since = now_ns -| input_ns;
+ if (since < hold_ns) return .{ .alpha = 1, .edge = false, .next_ns = input_ns + hold_ns + half_ns - edge_ns };
+ if (since >= hold_ns + idle_ns) return .{ .alpha = 1, .edge = false, .next_ns = null };
+ const t = (since - hold_ns) % (2 * half_ns);
+ const cycle_start = now_ns - t;
+ // On for a half, easing out over its last `edge_ns`; off for a half,
+ // easing back in over its last.
+ if (t < half_ns - edge_ns) return .{ .alpha = 1, .edge = false, .next_ns = cycle_start + half_ns - edge_ns };
+ if (t < half_ns) return .{ .alpha = 1 - smooth(@as(f32, @floatFromInt(t - (half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null };
+ if (t < 2 * half_ns - edge_ns) return .{ .alpha = 0, .edge = false, .next_ns = cycle_start + 2 * half_ns - edge_ns };
+ return .{ .alpha = smooth(@as(f32, @floatFromInt(t - (2 * half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null };
+ }
+
+ fn smooth(x: f32) f32 {
+ const u = std.math.clamp(x, 0, 1);
+ return u * u * (3 - 2 * u);
+ }
+};
+
+test "a cursor snaps a cell at a time and glides a jump, smearing, bowing and landing exactly" {
+ const ms = std.time.ns_per_ms;
+ const smooth_motion = Motion.of(.smooth);
+ var glide: CursorGlide = .{};
+ const cell: Box = .{ .x = 3, .y = 4, .w = 1, .h = 1 };
+ glide.aim(cell, 0, smooth_motion, false);
+ try std.testing.expect(!glide.moving());
+ // One cell over: lands at once.
+ glide.aim(.{ .x = 4, .y = 4, .w = 1, .h = 1 }, ms, smooth_motion, false);
+ try std.testing.expect(!glide.moving());
+ // A jump of 30 cells: glides, the leading edge ahead of the trailing one.
+ const far: Box = .{ .x = 34, .y = 4, .w = 1, .h = 1 };
+ glide.aim(far, 2 * ms, smooth_motion, false);
+ try std.testing.expect(glide.moving());
+ const mid = glide.sample(40 * ms, smooth_motion);
+ const width = mid[1][0] - mid[0][0];
+ try std.testing.expect(width > 1.5);
+ // Bowed off the straight line on a long jump.
+ try std.testing.expect(@abs(mid[0][1] - 4) > 0.05);
+ var t: u64 = 2 * ms;
+ while (glide.step(t)) t += ms;
+ // Arrives within §8.1's 90-150 ms on the default, within a twentieth of
+ // a cell (under a pixel); settles to rest just after, landing exactly.
+ const arrived = glide.sample(152 * ms, smooth_motion);
+ try std.testing.expect(@abs(arrived[3][0] - 34) < 0.05 and @abs(arrived[1][0] - 35) < 0.05);
+ try std.testing.expect(t <= 220 * ms);
+ const rest = glide.sample(t, smooth_motion);
+ try std.testing.expectEqual([2]f32{ 34, 4 }, rest[0]);
+ try std.testing.expectEqual([2]f32{ 35, 5 }, rest[2]);
+ // A scroll carries it with the text.
+ glide.shift(0, -3);
+ try std.testing.expectEqual([2]f32{ 34, 1 }, glide.sample(t, smooth_motion)[0]);
+}
+
+test "a blink holds while typing, eases at its edges and stops when idle" {
+ const ms = std.time.ns_per_ms;
+ try std.testing.expectEqual(@as(f32, 1), Blink.at(100 * ms, 0).alpha);
+ const out = Blink.at(Blink.hold_ns + Blink.half_ns + 100 * ms, 0);
+ try std.testing.expectEqual(@as(f32, 0), out.alpha);
+ const edge = Blink.at(Blink.hold_ns + Blink.half_ns - Blink.edge_ns / 2, 0);
+ try std.testing.expect(edge.edge and edge.alpha > 0.2 and edge.alpha < 0.8);
+ try std.testing.expectEqual(@as(?u64, null), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).next_ns);
+ try std.testing.expectEqual(@as(f32, 1), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).alpha);
+}
+
/// A displayed value that fades from one target to the next over
/// `transition_steps` frames; the chrome colours are one.
pub fn Fade(comptime Value: type) type {