diff options
Diffstat (limited to 'src/draw.zig')
| -rw-r--r-- | src/draw.zig | 256 |
1 files changed, 233 insertions, 23 deletions
diff --git a/src/draw.zig b/src/draw.zig index 6c0cc077..15411770 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -63,7 +63,8 @@ pub fn place(p: *Pardes, s: *Surface) void { // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; - s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); + const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; + s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .line = line, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its // pages are placed (Pdf.draw); every other pane's is here. @@ -211,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; @@ -219,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)); @@ -248,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 @@ -472,7 +552,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), .name_ink = chrome.tag_name_fg, .active_name_ink = if (p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg, - .selection_glow = @intFromBool(p.settings.selection_glow), + .fx = @as(u8, @intFromBool(p.settings.selection_glow)) | @as(u8, @intFromBool(p.settings.hover_glow)) << 1 | @as(u8, @intFromBool(p.settings.occlusion)) << 2 | @as(u8, @intFromBool(p.settings.parallax)) << 3 | @as(u8, @intFromBool(p.settings.afterglow)) << 4, }; if (p.theme().decor) |decor| { s.chrome.decor_dots = decor.page_dots; @@ -666,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); } @@ -722,6 +804,7 @@ fn composedCell( return switch (animation.charSource(track, col - area.x0, row - area.y0, area)) { .old => canonical.previous_cells[index], .byte => |byte| withByte(canonical.cells[index], byte), + .edge => |weight| towardGround(canonical.cells[index], canonical.previous_cells[index].style.bg, weight), // Churn belongs on the glyph, not on the pane's empty space, and it // borrows whichever side of the diff actually has one. .churn => |byte| blk: { @@ -748,6 +831,25 @@ fn composedCell( }; } +/// A cell with its ink and ground drawn `weight` of the way toward another +/// ground, in OKLab. A palette or default colour cannot be mixed, so the +/// ground steps over at the halfway point instead. +fn towardGround(cell: Cell, ground: Color, weight: f32) Cell { + var out = cell; + const to = switch (ground) { + .rgb => |rgb| rgb, + .default, .index => return if (weight >= 0.5) blk: { + out.style.bg = ground; + break :blk out; + } else out, + }; + inline for (.{ &out.style.fg, &out.style.bg }) |color| switch (color.*) { + .rgb => |rgb| color.* = .{ .rgb = colors.mixOklab(rgb, to, weight) }, + .default, .index => {}, + }; + return out; +} + fn withByte(cell: Cell, byte: u8) Cell { var out = cell; out.text[0] = byte; @@ -1685,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); @@ -2038,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); +} |
