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/draw.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/draw.zig')
| -rw-r--r-- | src/draw.zig | 163 |
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: |
