diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 23:34:20 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | faa5ee1d81e2b9c4be6925b7178db2d2dc4577f8 (patch) | |
| tree | 14799bbd5748743b985ac617ba134ad12bdc9269 /src/animation.zig | |
| parent | d8d770454e77fbeeb0dd9c5e965a7eda6a5558ba (diff) | |
| download | pardes-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.zig | 201 |
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 { |
