diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 10:03:10 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 3d262763b304021720f1141ab33265759869d2fe (patch) | |
| tree | 5c2e67cd794a4bc651f4ae75037e7f46c74ca5f2 /src/draw.zig | |
| parent | 826ec1fac3c012e71ab34207328faaac5b5827b7 (diff) | |
| download | pardes-3d262763b304021720f1141ab33265759869d2fe.tar.gz pardes-3d262763b304021720f1141ab33265759869d2fe.zip | |
The focused cursor rides with a moving pane, and a focus change glides to the new one
G3's springs now keep the cursor's place in its pane: a pane that moves in
the layout carries them, and the quad is drawn through the pane's presented
box (its transition track), so through a slide, zoom or drag the cursor
rides with the text, and with no transition it lands with it. A focus change,
to another pane or into a column's or the workspace's tag, glides from where
the cursor was drawn to the new place instead of snapping, unclamped while it
crosses. While panes move the cursor is drawn as the overlay quad instead of
vanishing (golden 18-mid-transition re-recorded for that: the only change is
the quad, gliding into the new column's tag).
Lapis: a tag plaque starts flush against its grip's button, the scroll gap
between them part of the plaque, so grip and plaque read as one piece, on
pane and column rows alike; below the button's row the grip keeps its band.
Themes without plaques are unchanged (their grip band and tag band are one
colour, no sliver).
Diffstat (limited to 'src/draw.zig')
| -rw-r--r-- | src/draw.zig | 231 |
1 files changed, 210 insertions, 21 deletions
diff --git a/src/draw.zig b/src/draw.zig index b0b16e12..15411770 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -212,6 +212,14 @@ fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box { /// 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. +/// +/// The springs hold the cursor where the pane's final layout puts it, and +/// the quad is drawn through the pane's frame (paneFrame): while the pane +/// slides, zooms or is dragged, the cursor rides with its text. A pane that +/// moved in the layout carries the springs with it (they keep their place +/// in the pane), so with no transition the cursor lands with the text. A +/// focus change glides from where the cursor is drawn to the new pane's +/// cursor, wherever that pane is drawn now. fn glideCursor(p: *Pardes, s: *Surface) void { s.chrome.cursor_glide = 0; s.chrome.cursor_alpha = 1; @@ -220,26 +228,59 @@ fn glideCursor(p: *Pardes, s: *Surface) void { 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; + var frame: PaneFrame = .{}; + // In a column's or the workspace's tag the cursor is not the pane's, + // and does not move with it. + var inside = 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 rect = p.rects[p.active]; + const cx = box.x + box.w / 2; + const cy = box.y + box.h / 2; + inside = cx >= @as(f32, @floatFromInt(rect.x)) and cx <= @as(f32, @floatFromInt(rect.x + rect.w)) and + cy >= @as(f32, @floatFromInt(rect.y)) and cy <= @as(f32, @floatFromInt(rect.y + rect.h)); + if (inside) frame = paneFrame(s, pane.serial, rect); + const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) }; + const view = p.cursor_view; 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; + if (view.serial == pane.serial and view.inside == inside) { + // The pane moved in the layout: the springs move with it (its + // own cursor's; one in a column's tag stays). + if (inside) p.cursor_glide.shift(origin[0] - view.origin[0], origin[1] - view.origin[1]); + // 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). + if (inside and line != view.line) { + p.cursor_glide.shift(0, @floatFromInt(view.line - line)); + snap = true; + } + } else if (view.serial != 0 and p.cursor_glide.placed) { + // Focus moved (to another pane, or between a pane and a + // column's or the workspace's tag): the glide starts where the + // cursor is drawn, in the old place's frame, and heads for the + // new cursor through the new one's. + const old: PaneFrame = if (!view.inside) .{} else for (p.panes, 0..) |slot, id| { + const other = slot orelse continue; + if (other.serial != view.serial) continue; + const r = p.rects[id]; + p.cursor_glide.shift(@as(f32, @floatFromInt(r.x)) - view.origin[0], @as(f32, @floatFromInt(r.y)) - view.origin[1]); + break paneFrame(s, view.serial, r); + } else .{}; + const at = p.cursor_glide.centreAt(p.now_ns); + const seen = frame.unplace(old.place(at)); + p.cursor_glide.shift(seen[0] - at[0], seen[1] - at[1]); + p.cursor_crossing = true; } - p.cursor_view = .{ .serial = pane.serial, .line = line }; + p.cursor_view = .{ .serial = pane.serial, .line = line, .origin = origin, .inside = inside }; } 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)); + if (!moving) p.cursor_crossing = false; + // Drawn as the quad while it glides and while its pane moves. + s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving or frame.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)); @@ -249,13 +290,51 @@ fn glideCursor(p: *Pardes, s: *Surface) void { 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))); + // Never outside the focused pane as drawn, however far a flavour + // overshoots or a scroll carried it: it squashes against the pane's + // edges. Crossing to another pane, it may pass over any of the grid. + const bounds: animation.Box = if (p.cursor_crossing or !inside) + .{ .w = @floatFromInt(s.cols), .h = @floatFromInt(s.rows) } + else + frame.at; + for (quad, 0..) |corner, i| { + const point = frame.place(corner); + s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], bounds.x, bounds.x + bounds.w); + s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], bounds.y, bounds.y + bounds.h); + } +} + +/// Where a pane is drawn against where the layout puts it: its transition +/// track's presented box against the track's target, or the layout's box +/// both ways when it is still. Maps grid points between the two. +const PaneFrame = struct { + to: animation.Box = .{ .w = 1, .h = 1 }, + at: animation.Box = .{ .w = 1, .h = 1 }, + + fn moving(f: PaneFrame) bool { + return !std.meta.eql(f.to, f.at); + } + + fn place(f: PaneFrame, point: [2]f32) [2]f32 { + return .{ f.at.x + (point[0] - f.to.x) * scale(f.at.w, f.to.w), f.at.y + (point[1] - f.to.y) * scale(f.at.h, f.to.h) }; + } + + fn unplace(f: PaneFrame, point: [2]f32) [2]f32 { + return .{ f.to.x + (point[0] - f.at.x) * scale(f.to.w, f.at.w), f.to.y + (point[1] - f.at.y) * scale(f.to.h, f.at.h) }; + } + + fn scale(num: f32, den: f32) f32 { + return if (den > 0.001) num / den else 1; + } +}; + +fn paneFrame(s: *const Surface, serial: u32, rect: Rect) PaneFrame { + const still = layout.panelBox(rect); + for (s.panelTracks()) |track| { + if (track.serial != serial or track.phase == .closing or !track.active()) continue; + return .{ .to = track.to, .at = track.visualBox() }; } + return .{ .to = still, .at = still }; } /// The Lift style as drawn: `auto` is a shadow on a light page, and on a @@ -667,8 +746,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { } } }; - glideCursor(p, s); + // After the tracks are submitted: the cursor is drawn through its + // pane's. try p.presentation.submit(p, s); + glideCursor(p, s); return composeAsciiTransitions(p, arena, s); } @@ -1706,11 +1787,13 @@ test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixte try std.testing.expect(reverse.bg.rgb[0] < 255); } -test "with no lift and no dim a focus switch asks for no frames" { +test "with no lift, no dim and no motion a focus switch asks for no frames" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); + // With motion the cursor glides to the other pane (glideCursor). + p.settings.motion = .off; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.advance(std.time.ns_per_s); @@ -2059,3 +2142,109 @@ test "a body with context rows is painted once, and the grid's rows are what a p try std.testing.expect(layer.rows > body.rect.h); } } + +/// Two columns, the file in the left one and a shell in the right, the +/// right one focused with its cursor settled. +fn twoColumns(p: *Pardes, arena: *std.heap.ArenaAllocator) !void { + _ = try p.setTestFile("some text on a line\n" ** 6); + _ = try p.newShell(1, ""); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); + p.sync(); + p.advance(std.time.ns_per_s); + const boot = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(boot.panelTracks()); + p.advance(2 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const settled = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(settled.panelTracks()); + try std.testing.expect(!p.cursor_glide.moving()); +} + +test "while its pane slides the cursor rides with it, at the same cell of the pane every frame" { + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.motion = .bouncy; + try twoColumns(p, &arena); + p.active = 1; + p.advance(3 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.advance(4 * std.time.ns_per_s); + p.settings.panel_transition = .slide; + // The right column's pane moves right. + p.col_weight[0] = layout.column_weight_unit * 2; + p.col_weight[1] = layout.column_weight_unit; + p.sync(); + var now: u64 = 4 * std.time.ns_per_s; + var riding: usize = 0; + for (0..40) |_| { + now += 16 * std.time.ns_per_ms; + p.advance(now); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(s.panelTracks()); + const frame = paneFrame(s, p.panes[1].?.serial, p.rects[1]); + const box = focusedCursorBox(p, s).?; + const want = frame.place(.{ box.x, box.y }); + try std.testing.expectApproxEqAbs(want[0], s.chrome.cursor_quad[0], 0.01); + try std.testing.expectApproxEqAbs(want[1], s.chrome.cursor_quad[1], 0.01); + if (frame.moving()) { + riding += 1; + // Drawn as the quad while the pane moves. + try std.testing.expect(s.chrome.cursor_glide & 1 != 0); + } + } + try std.testing.expect(riding >= 3); + + // With no transition, the cursor lands with the text at once. + p.settings.panel_transition = .off; + p.col_weight[0] = layout.column_weight_unit; + p.col_weight[1] = layout.column_weight_unit * 2; + p.sync(); + now += 16 * std.time.ns_per_ms; + p.advance(now); + _ = arena.reset(.retain_capacity); + const landed = try p.render(arena.allocator()); + const box = focusedCursorBox(p, landed).?; + try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); + try std.testing.expect(!p.cursor_glide.moving()); +} + +test "focus moved to another column glides from where the cursor was drawn" { + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.motion = .smooth; + try twoColumns(p, &arena); + p.active = 0; + p.advance(3 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const before = try p.render(arena.allocator()); + try std.testing.expect(!p.cursor_glide.moving()); + const was: [2]f32 = .{ before.chrome.cursor_quad[0], before.chrome.cursor_quad[1] }; + + p.active = 1; + _ = arena.reset(.retain_capacity); + const start = try p.render(arena.allocator()); + // It sets off from where it was drawn, in the left column... + try std.testing.expectApproxEqAbs(was[0], start.chrome.cursor_quad[0], 0.01); + try std.testing.expectApproxEqAbs(was[1], start.chrome.cursor_quad[1], 0.01); + try std.testing.expect(p.cursor_glide.moving() and start.chrome.cursor_glide & 1 != 0); + // ...crosses the grid, not squashed against the right one's edge... + p.advance(3 * std.time.ns_per_s + 40 * std.time.ns_per_ms); + _ = arena.reset(.retain_capacity); + const mid = try p.render(arena.allocator()); + try std.testing.expect(mid.chrome.cursor_quad[0] > was[0] and mid.chrome.cursor_quad[0] < @as(f32, @floatFromInt(p.rects[1].x)) + 1); + // ...and lands on the right one's cursor. + p.advance(5 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const landed = try p.render(arena.allocator()); + const box = focusedCursorBox(p, landed).?; + try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); + try std.testing.expect(!p.cursor_glide.moving() and !p.cursor_crossing); +} |
