summaryrefslogtreecommitdiff
path: root/src/draw.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/draw.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/draw.zig')
-rw-r--r--src/draw.zig163
1 files changed, 163 insertions, 0 deletions
diff --git a/src/draw.zig b/src/draw.zig
index 963d61c1..8bb4f23e 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -184,6 +184,76 @@ fn inkOn(bg: [3]u8) [3]u8 {
return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
}
+/// The focused cursor's box in grid cells, at the pitch it is drawn at: a
+/// tag's at the tagline's (Layer.Metrics), else a grid cell.
+fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box {
+ const cursor = s.cursor orelse return null;
+ const m = p.row_metrics;
+ const bw: f32 = @floatFromInt(@max(1, m.body_w));
+ const bh: f32 = @floatFromInt(@max(1, m.body_h));
+ const tw: f32 = @floatFromInt(@max(1, m.tagline_w));
+ const th: f32 = @floatFromInt(@max(1, @min(m.tagline_h, m.body_h)));
+ for (s.tagLayers()) |*layer| {
+ const caret = layer.cursor orelse continue;
+ if (layer.rows == 0 or layer.viewport.y + caret.y != cursor.y) continue;
+ if (cursor.x < layer.viewport.x or cursor.x >= layer.viewport.x + layer.viewport.w) continue;
+ const x = @as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * tw / bw;
+ const w = if (caret.bar) tw / bw / 8 else tw / bw;
+ return .{ .x = x, .y = @as(f32, @floatFromInt(cursor.y)) + (bh - th) / 2 / bh, .w = w, .h = th / bh };
+ }
+ return .{ .x = @floatFromInt(cursor.x), .y = @floatFromInt(cursor.y), .w = if (cursor.bar) 1.0 / 8.0 else 1, .h = 1 };
+}
+
+/// G3 (docs/render-pipeline.md ยง5.4): the focused cursor on its springs,
+/// at the Motion flavour's pace, a jump gliding and a step or a keystroke in
+/// insert mode landing at once; blinking unless typing. A pixel shell's: a
+/// grid's cursor is the terminal's own.
+fn glideCursor(p: *Pardes, s: *Surface) void {
+ s.chrome.cursor_glide = 0;
+ s.chrome.cursor_alpha = 1;
+ s.chrome.cursor_idle_ms = std.math.maxInt(u32);
+ if (!builtins.capabilities.lift) return;
+ const box = focusedCursorBox(p, s) orelse return;
+ const motion = animation.Motion.of(p.settings.motion);
+ var snap = if (s.cursor) |cursor| cursor.bar else false;
+ if (p.panes[p.active]) |pane| {
+ // A focus change lands at once: a glide between panes would be
+ // squashed against the new one's edge.
+ if (p.cursor_view.serial != pane.serial) snap = true;
+ // Typing never trails: insert mode lands every move.
+ if (pane.body.mode == .insert) snap = true;
+ // Scrolled (a file's view, a terminal's output): the quad moves with
+ // the text and lands, never gliding against text that jumped (every
+ // Enter at a prompt scrolls one and moves the other).
+ const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0;
+ if (p.cursor_view.serial == pane.serial and line != p.cursor_view.line) {
+ p.cursor_glide.shift(0, @floatFromInt(p.cursor_view.line - line));
+ snap = true;
+ }
+ p.cursor_view = .{ .serial = pane.serial, .line = line };
+ }
+ p.cursor_glide.aim(box, p.now_ns, motion, snap);
+ _ = p.cursor_glide.step(p.now_ns);
+ const moving = p.cursor_glide.moving();
+ s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving));
+ // The shell blinks it, from how long ago the last key or click was.
+ if (p.settings.cursor_blink)
+ s.chrome.cursor_idle_ms = @intCast(@min(std.math.maxInt(u32) - 1, (p.now_ns -| p.input_ns) / std.time.ns_per_ms));
+ // A little less than solid while it glides, so the glyphs it passes
+ // stay legible (less on the long flavours), solid again as it lands.
+ const remaining = p.cursor_glide.remaining(p.now_ns);
+ const see_through: f32 = if (motion.zeta < 1) 0.7 else 0.85;
+ s.chrome.cursor_alpha = if (moving) see_through + (1 - see_through) * std.math.clamp(1 - remaining / 0.5, 0, 1) else 1;
+ const quad = p.cursor_glide.sample(p.now_ns, motion);
+ // Never outside the focused pane, however far a flavour overshoots or
+ // a scroll carried it: it squashes against the pane's edges.
+ const r = p.rects[p.active];
+ for (quad, 0..) |point, i| {
+ s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], @as(f32, @floatFromInt(r.x)), @as(f32, @floatFromInt(r.x + r.w)));
+ s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], @as(f32, @floatFromInt(r.y)), @as(f32, @floatFromInt(r.y + r.h)));
+ }
+}
+
/// The Lift style as drawn: `auto` is a shadow on a light page, and on a
/// dark one, where a shadow has nothing to darken, no lift at all (the other
/// panes recede instead, `inactiveDim`).
@@ -582,6 +652,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
}
}
};
+ glideCursor(p, s);
try p.presentation.submit(p, s);
return composeAsciiTransitions(p, arena, s);
}
@@ -1653,6 +1724,98 @@ test "each Motion flavour reaches the lift as its own trajectory" {
try std.testing.expect(peaks[2] <= 1.001 and peaks[3] > 1.25 and peaks[4] > peaks[3]);
}
+test "the focused cursor glides a jump, lands a step at once, and is exact at rest" {
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 30 });
+ defer p.deinit();
+ _ = try p.setTestFile("some text on a line\n" ** 20);
+ p.settings.cursor_blink = false;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ var now: u64 = std.time.ns_per_s;
+ p.advance(now);
+ _ = try p.render(arena.allocator());
+ // A step down: at once.
+ p.update(.{ .key = .{ .cp = 'j' } });
+ now += 16 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ var s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+ const before = s.cursor.?;
+ // To the last line: it glides, a quad partway, and asks for frames.
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'e' } });
+ now += 16 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ const target = s.cursor.?;
+ try std.testing.expect(target.y > before.y + 5);
+ try std.testing.expectEqual(@as(u8, 3), s.chrome.cursor_glide);
+ try std.testing.expect(s.chrome.cursor_quad[1] < @as(f32, @floatFromInt(target.y)) - 1);
+ try std.testing.expect(p.continuous());
+ // Frame by frame to rest: gliding exactly while it moves (the shell then
+ // draws the quad, never the cells' cursor too), at under full alpha
+ // until it lands, solid as it does.
+ var frames: usize = 0;
+ while (p.continuous()) : (frames += 1) {
+ now += 7 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ try std.testing.expect(s.chrome.cursor_glide & 2 != 0);
+ try std.testing.expectEqual(p.cursor_glide.moving(), s.chrome.cursor_glide & 1 != 0);
+ if (s.chrome.cursor_glide & 1 != 0) try std.testing.expect(s.chrome.cursor_alpha <= 1 and s.chrome.cursor_alpha >= 0.7);
+ for (s.chrome.cursor_quad) |value| try std.testing.expect(std.math.isFinite(value));
+ }
+ try std.testing.expect(frames > 3);
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+ try std.testing.expectEqual(@as(f32, 1), s.chrome.cursor_alpha);
+ // At rest the quad is the cursor's own cell.
+ try std.testing.expectEqual(@as(f32, @floatFromInt(target.x)), s.chrome.cursor_quad[0]);
+ try std.testing.expectEqual(@as(f32, @floatFromInt(target.y)), s.chrome.cursor_quad[1]);
+ // A mode change mid-glide lands at once: insert mode never trails.
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ now += 7 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 3), s.chrome.cursor_glide);
+ p.update(.{ .key = .{ .cp = 'i' } });
+ now += 7 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+}
+
+test "a terminal's cursor lands with scrolling output, never gliding against it" {
+ if (!builtins.capabilities.lift or !pardes.terminal_panes) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const term = p.active;
+ try std.testing.expect(p.panes[term].?.isTerminal());
+ p.settings.cursor_blink = false;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ var now: u64 = std.time.ns_per_s;
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "line\r\n" ** 20 ++ "$ some command typed" } });
+ p.advance(now);
+ _ = try p.render(arena.allocator());
+ // Enter: three lines of output scroll up, the cursor to a new prompt.
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\r\nout\r\nout\r\nout\r\n$ " } });
+ now += 16 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+}
+
test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" {
// The snapshot harness runs the tty, whose tagline pitch is the body's,
// so it never reaches what a narrower tagline does to a notice's band: