summaryrefslogtreecommitdiff
path: root/src/draw.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/draw.zig')
-rw-r--r--src/draw.zig256
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);
+}