summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/config.md2
-rw-r--r--docs/effects.md11
-rw-r--r--src/animation.zig201
-rw-r--r--src/config.zig12
-rw-r--r--src/detached/wire.zig22
-rw-r--r--src/draw.zig163
-rw-r--r--src/gui/gui.zig84
-rw-r--r--src/pardes.zig10
-rw-r--r--src/surface.zig10
9 files changed, 504 insertions, 11 deletions
diff --git a/docs/config.md b/docs/config.md
index 0332d854..8c986d81 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -545,6 +545,8 @@ Lift auto a shadow on a light page; on a dark one the others recede
Lift off
InactiveDim 30 the unfocused panes' text fades 30% toward its ground
Motion smooth off, crisp, smooth (the default), bouncy or playful
+CursorBlink on the cursor blinks, solid while typing and half a second
+ after, eased at each edge, solid after 10 idle seconds
GripWidth 150 the grip's button and the scrollbar under it, percent of the
theme's rail_px (12px at a 17px tagline; 50 to 300)
```
diff --git a/docs/effects.md b/docs/effects.md
index 3f375b7d..08fd3d88 100644
--- a/docs/effects.md
+++ b/docs/effects.md
@@ -23,6 +23,7 @@ why. The verdict is the user's.
| Crt (bundled post pass) | `Crt 0..3` | kept, rewritten (no barrel) | fx-compare stills, live window | kept at the user's live look |
| Ripple, Glitch | — | removed | live window | dropped by the user after a live look |
| G1 lift | `Lift shadow\|rim\|auto` (off), `InactiveDim <percent>` | opt-in until the focus-lift default is decided | lift-shots-2 (off, shadow, rim, auto on acme, dusk, forge); frame series + mp4 per Motion flavour | shadow on acme: keep as is. Round 1 dropped glow and surface (surface lowered the focused text's contrast) and made rim a hairline just above the focused tag (light on a dark page, shade on a light one). auto on a dark page recedes the others (InactiveDim 30) |
+| G3 cursor | follows `Motion`; `CursorBlink on\|off` (on) | glide on by default through the default flavour (smooth); blink on (decision 6) | .scratch/render/cursor/cursor-flavours-grid.mp4 (and quarter speed), raw/<flavour>/<motion>/ | pending |
## Motion flavours
@@ -64,9 +65,13 @@ Which principles each motion uses:
spring over the move's length; anticipation (playful). A pane past its mark
never takes a neighbour's click, a rising one stays in its box, and a notice
past its row stays in its pane's body.
-- **Cursor** (G3, next): designed around the same set: glide on the spring,
- stretch along the path, an arc on long jumps, the trailing corners as the
- secondary action.
+- **Cursor** (G3): timing (the flavour's spring, 2.4x its pace: it follows
+ input), follow-through (bouncy and playful pass the target), squash and
+ stretch and secondary action (the trailing corners lag by the flavour's
+ follow, so a jump smears and collapses), arcs (a jump over 8 cells bows by
+ the flavour's arc), anticipation (playful's wind-up). A step of a cell or
+ less, or any move in insert mode, lands at once, so typing never trails;
+ a scroll carries the cursor with the text. Only the focused cursor moves.
Notes on G1: the lift and the dim run on one focus spring per pane, at the
diff --git a/src/animation.zig b/src/animation.zig
index 9b43644e..9002a2c6 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -870,6 +870,207 @@ test "a flavour's stretched pane lands exactly on its box, and a click finds the
}
}
+/// The focused cursor's quad (docs/render-pipeline.md §5.4), in grid cells:
+/// four corners, each on its own springs, so a jump smears along its path
+/// (the leading corners stiffer than the trailing ones, Motion.follow) and
+/// collapses at rest; a long jump bows (Motion.arc). A move of a cell or
+/// less, or any the caller calls a snap, lands at once.
+pub const CursorGlide = struct {
+ /// Top left, top right, bottom right, bottom left; x then y.
+ corners: [4][2]Spring = @splat(@splat(.{})),
+ target: Box = .{},
+ from_centre: [2]f32 = .{ 0, 0 },
+ placed: bool = false,
+
+ /// How much faster than the flavour's own pace a cursor moves: it
+ /// follows input (§8.1: settles in 90-150 ms on the default).
+ pub const pace: f32 = 2.4;
+ /// A jump longer than this, in cells, bows.
+ pub const arc_from: f32 = 8;
+
+ fn cornersOf(box: Box) [4][2]f32 {
+ return .{ .{ box.x, box.y }, .{ box.x + box.w, box.y }, .{ box.x + box.w, box.y + box.h }, .{ box.x, box.y + box.h } };
+ }
+
+ fn centre(box: Box) [2]f32 {
+ return .{ box.x + box.w / 2, box.y + box.h / 2 };
+ }
+
+ /// Heads for `box` at `now_ns`.
+ pub fn aim(glide: *CursorGlide, box: Box, now_ns: u64, motion: Motion, snap: bool) void {
+ // The same mark: nothing to do, unless a snap lands a glide under way.
+ if (glide.placed and std.meta.eql(box, glide.target) and !(snap and glide.moving())) return;
+ const to = cornersOf(box);
+ const from_c = if (glide.placed) glide.centreAt(now_ns) else centre(box);
+ const to_c = centre(box);
+ const dx = to_c[0] - from_c[0];
+ const dy = to_c[1] - from_c[1];
+ const jump = @abs(dx) > 1.01 or @abs(dy) > 1.01;
+ const land = !glide.placed or snap or motion.instant or !jump;
+ glide.from_centre = from_c;
+ glide.target = box;
+ glide.placed = true;
+ for (&glide.corners, to) |*corner, point| {
+ // Leading: the corner ahead of the move, on the side it goes.
+ const ahead = (point[0] - to_c[0]) * dx + (point[1] - to_c[1]) * dy;
+ var own = motion;
+ own.omega *= pace * (if (ahead > 0) 1 else motion.follow);
+ if (land) {
+ for (corner, point) |*axis, value| axis.* = .{ .omega = own.omega, .zeta = own.zeta, .target = value, .from = value };
+ continue;
+ }
+ for (corner, point) |*axis, value| axis.retarget(value, now_ns, own);
+ }
+ }
+
+ fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 {
+ var c: [2]f32 = .{ 0, 0 };
+ for (glide.corners) |corner| for (&c, corner) |*sum, axis| {
+ sum.* += axis.value(now_ns) / 4;
+ };
+ return c;
+ }
+
+ /// Text under the cursor moved by (dx, dy) cells (a scroll): the quad
+ /// moves with it, rather than gliding against text that jumped.
+ pub fn shift(glide: *CursorGlide, dx: f32, dy: f32) void {
+ for (&glide.corners) |*corner| for (corner, [2]f32{ dx, dy }) |*axis, d| {
+ axis.from += d;
+ axis.target += d;
+ };
+ glide.from_centre = .{ glide.from_centre[0] + dx, glide.from_centre[1] + dy };
+ glide.target.x += dx;
+ glide.target.y += dy;
+ }
+
+ /// The quad at `now_ns`, bowed on a long jump by the flavour's arc.
+ pub fn sample(glide: *const CursorGlide, now_ns: u64, motion: Motion) [4][2]f32 {
+ var out: [4][2]f32 = undefined;
+ for (&out, glide.corners) |*point, corner| for (point, corner) |*value, axis| {
+ value.* = axis.value(now_ns);
+ };
+ const to_c = centre(glide.target);
+ const dx = to_c[0] - glide.from_centre[0];
+ const dy = to_c[1] - glide.from_centre[1];
+ const length = @sqrt(dx * dx + dy * dy);
+ if (motion.arc == 0 or length < arc_from) return out;
+ const now_c = glide.centreAt(now_ns);
+ const left = @sqrt((to_c[0] - now_c[0]) * (to_c[0] - now_c[0]) + (to_c[1] - now_c[1]) * (to_c[1] - now_c[1]));
+ const travelled = std.math.clamp(1 - left / length, 0, 1);
+ const bow = motion.arc * length * 4 * travelled * (1 - travelled);
+ // Perpendicular to the path, to its left.
+ for (&out) |*point| {
+ point[0] += -dy / length * bow;
+ point[1] += dx / length * bow;
+ }
+ return out;
+ }
+
+ /// Steps the springs to `now_ns`; true while the quad still moves.
+ pub fn step(glide: *CursorGlide, now_ns: u64) bool {
+ var any = false;
+ for (&glide.corners) |*corner| for (corner) |*axis| {
+ if (axis.step(now_ns)) any = true;
+ };
+ return any;
+ }
+
+ /// How far, in cells, the furthest corner still is from its mark.
+ pub fn remaining(glide: *const CursorGlide, now_ns: u64) f32 {
+ var most: f32 = 0;
+ for (glide.corners) |corner| for (corner) |axis| {
+ most = @max(most, @abs(axis.value(now_ns) - axis.target));
+ };
+ return most;
+ }
+
+ pub fn moving(glide: *const CursorGlide) bool {
+ for (glide.corners) |corner| for (corner) |axis| {
+ if (!axis.settled) return true;
+ };
+ return false;
+ }
+};
+
+/// The cursor's opacity under blink (§5.4): solid while typing and for
+/// `hold_ns` after, then on and off with an eased edge, solid again after
+/// `idle_ns` of no input. `edge` says whether it is mid-edge (a frame is
+/// owed), `next_ns` when the next edge starts (a wake).
+pub const Blink = struct {
+ pub const hold_ns: u64 = 500 * std.time.ns_per_ms;
+ pub const idle_ns: u64 = 10 * std.time.ns_per_s;
+ pub const half_ns: u64 = 530 * std.time.ns_per_ms;
+ pub const edge_ns: u64 = 80 * std.time.ns_per_ms;
+
+ alpha: f32,
+ edge: bool,
+ next_ns: ?u64,
+
+ pub fn at(now_ns: u64, input_ns: u64) Blink {
+ const since = now_ns -| input_ns;
+ if (since < hold_ns) return .{ .alpha = 1, .edge = false, .next_ns = input_ns + hold_ns + half_ns - edge_ns };
+ if (since >= hold_ns + idle_ns) return .{ .alpha = 1, .edge = false, .next_ns = null };
+ const t = (since - hold_ns) % (2 * half_ns);
+ const cycle_start = now_ns - t;
+ // On for a half, easing out over its last `edge_ns`; off for a half,
+ // easing back in over its last.
+ if (t < half_ns - edge_ns) return .{ .alpha = 1, .edge = false, .next_ns = cycle_start + half_ns - edge_ns };
+ if (t < half_ns) return .{ .alpha = 1 - smooth(@as(f32, @floatFromInt(t - (half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null };
+ if (t < 2 * half_ns - edge_ns) return .{ .alpha = 0, .edge = false, .next_ns = cycle_start + 2 * half_ns - edge_ns };
+ return .{ .alpha = smooth(@as(f32, @floatFromInt(t - (2 * half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null };
+ }
+
+ fn smooth(x: f32) f32 {
+ const u = std.math.clamp(x, 0, 1);
+ return u * u * (3 - 2 * u);
+ }
+};
+
+test "a cursor snaps a cell at a time and glides a jump, smearing, bowing and landing exactly" {
+ const ms = std.time.ns_per_ms;
+ const smooth_motion = Motion.of(.smooth);
+ var glide: CursorGlide = .{};
+ const cell: Box = .{ .x = 3, .y = 4, .w = 1, .h = 1 };
+ glide.aim(cell, 0, smooth_motion, false);
+ try std.testing.expect(!glide.moving());
+ // One cell over: lands at once.
+ glide.aim(.{ .x = 4, .y = 4, .w = 1, .h = 1 }, ms, smooth_motion, false);
+ try std.testing.expect(!glide.moving());
+ // A jump of 30 cells: glides, the leading edge ahead of the trailing one.
+ const far: Box = .{ .x = 34, .y = 4, .w = 1, .h = 1 };
+ glide.aim(far, 2 * ms, smooth_motion, false);
+ try std.testing.expect(glide.moving());
+ const mid = glide.sample(40 * ms, smooth_motion);
+ const width = mid[1][0] - mid[0][0];
+ try std.testing.expect(width > 1.5);
+ // Bowed off the straight line on a long jump.
+ try std.testing.expect(@abs(mid[0][1] - 4) > 0.05);
+ var t: u64 = 2 * ms;
+ while (glide.step(t)) t += ms;
+ // Arrives within §8.1's 90-150 ms on the default, within a twentieth of
+ // a cell (under a pixel); settles to rest just after, landing exactly.
+ const arrived = glide.sample(152 * ms, smooth_motion);
+ try std.testing.expect(@abs(arrived[3][0] - 34) < 0.05 and @abs(arrived[1][0] - 35) < 0.05);
+ try std.testing.expect(t <= 220 * ms);
+ const rest = glide.sample(t, smooth_motion);
+ try std.testing.expectEqual([2]f32{ 34, 4 }, rest[0]);
+ try std.testing.expectEqual([2]f32{ 35, 5 }, rest[2]);
+ // A scroll carries it with the text.
+ glide.shift(0, -3);
+ try std.testing.expectEqual([2]f32{ 34, 1 }, glide.sample(t, smooth_motion)[0]);
+}
+
+test "a blink holds while typing, eases at its edges and stops when idle" {
+ const ms = std.time.ns_per_ms;
+ try std.testing.expectEqual(@as(f32, 1), Blink.at(100 * ms, 0).alpha);
+ const out = Blink.at(Blink.hold_ns + Blink.half_ns + 100 * ms, 0);
+ try std.testing.expectEqual(@as(f32, 0), out.alpha);
+ const edge = Blink.at(Blink.hold_ns + Blink.half_ns - Blink.edge_ns / 2, 0);
+ try std.testing.expect(edge.edge and edge.alpha > 0.2 and edge.alpha < 0.8);
+ try std.testing.expectEqual(@as(?u64, null), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).next_ns);
+ try std.testing.expectEqual(@as(f32, 1), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).alpha);
+}
+
/// A displayed value that fades from one target to the next over
/// `transition_steps` frames; the chrome colours are one.
pub fn Fade(comptime Value: type) type {
diff --git a/src/config.zig b/src/config.zig
index 24c48770..4581536e 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -119,6 +119,7 @@ pub const leader_path = paths: {
if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null);
if (builtins.capabilities.window_blur) table.set(.WindowBlur, null);
if (builtins.capabilities.ligatures) table.set(.Ligatures, null);
+ if (builtins.capabilities.lift) table.set(.CursorBlink, null);
if (builtins.capabilities.lift) table.set(.Lift, null);
table.set(.Motion, null);
table.set(.InactiveDim, null);
@@ -647,6 +648,10 @@ pub const Runtime = struct {
verbose: bool = true,
/// A message falls into its row and dissolves out of it.
message_animation: bool = true,
+ /// The cursor blinks, a pixel shell's (docs/render-pipeline.md §5.4):
+ /// solid while typing and for half a second after, eased at each edge,
+ /// solid again after ten idle seconds.
+ cursor_blink: bool = true,
/// How long a message stays after the key or click that dismisses it.
message_linger_ms: u16 = 800,
/// How long its ease-in fall into the row takes.
@@ -863,7 +868,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink };
/// The bundled post pass, a Shadertoy file in shaders/post/.
pub const Scene = enum { crt };
@@ -918,6 +923,7 @@ pub const Runtime = struct {
.{ .word = "Verbose", .action = .{ .toggle = .verbose } },
.{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
.{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures },
+ .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift },
.{ .word = "Lift", .action = .lift, .availability = .lift },
.{ .word = "Motion", .action = .motion },
.{ .word = "InactiveDim", .action = .inactive_dim },
@@ -990,6 +996,9 @@ pub const Runtime = struct {
.toggle => |field| if (field == .ligatures) {
if (setting.availability != .ligatures)
@compileError("the ligatures toggle must use the ligatures capability");
+ } else if (field == .cursor_blink) {
+ if (setting.availability != .lift)
+ @compileError("CursorBlink must use the lift capability (a pixel shell's)");
} else if (setting.availability != null)
@compileError("unconditional toggles cannot carry a backend capability"),
else => if (setting.availability != null)
@@ -1618,6 +1627,7 @@ pub const Runtime = struct {
.verbose => "Verbose off\n",
.message_animation => "MessageAnimation off\n",
.ligatures => "Ligatures off\n",
+ .cursor_blink => "CursorBlink off\n",
},
.shell => "Shell requested (new panes) fish\n",
.tagline_size => "TaglineSize 73\n",
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index c0e17054..53a1a70d 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -13,7 +13,7 @@ pub const version: u16 = 8;
/// The Chrome palette is carried field by field as its declaration lists
/// them, so any change to it changes the wire: bump `version`, then this.
-const chrome_layout: u64 = 0x7e3d0952317bfee;
+const chrome_layout: u64 = 0xc91196fcdef144e2;
comptime {
@setEvalBranchQuota(10_000);
var hash = std.hash.Fnv1a_64.init();
@@ -286,6 +286,16 @@ pub const Frame = struct {
}
} else if (field.type == u8) {
rgb.* = try r.getByte();
+ } else if (field.type == u32) {
+ rgb.* = try r.getU32();
+ } else if (field.type == f32) {
+ rgb.* = @bitCast(try r.getU32());
+ if (!std.math.isFinite(rgb.*)) return error.BadValue;
+ } else if (field.type == [8]f32) {
+ for (rgb) |*value| {
+ value.* = @bitCast(try r.getU32());
+ if (!std.math.isFinite(value.*)) return error.BadValue;
+ }
} else for (rgb) |*channel| channel.* = try r.getByte();
}
try r.end();
@@ -747,7 +757,7 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg
const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2 + 4;
/// The palette: each colour three bytes, a theme's optional ones a flag
/// before them.
-const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4;
+const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4 + 8 * 4;
pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) Error![]const u8 {
// The protocol's ceiling, enforced by the ENCODER too, and BEFORE the
@@ -837,6 +847,12 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
if (rgb) |value| for (value) |channel| try w.putByte(channel);
} else if (field.type == u8) {
try w.putByte(rgb);
+ } else if (field.type == u32) {
+ try w.putU32(rgb);
+ } else if (field.type == f32) {
+ try w.putU32(@bitCast(rgb));
+ } else if (field.type == [8]f32) {
+ for (rgb) |value| try w.putU32(@bitCast(value));
} else for (rgb) |channel| try w.putByte(channel);
}
}
@@ -1917,7 +1933,7 @@ test "detached wire carries compact body rows and exact pointer metrics" {
const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .dirty = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }};
const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed));
defer gpa.free(out);
- const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2 };
+ const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2, .cursor_glide = 3, .cursor_alpha = 0.5, .cursor_idle_ms = 1234, .cursor_quad = .{ 1, 2, 3.5, 2, 3.5, 3, 1, 3 } };
const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette);
const packet = (try framed(encoded)).?;
const frame = (try decodeServer(packet.tag, packet.payload)).frame;
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:
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 5dfacee9..7d097e83 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1857,6 +1857,12 @@ const Gui = struct {
scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane
scroll_edge_len: u16 = 0,
+ /// The cursor's blink (blinkNow): when the frame drawn last arrived,
+ /// a redraw owed for an edge, and this frame's cursor mode and alpha.
+ frame_arrival_ns: u64 = 0,
+ blink_due: bool = false,
+ no_blink: bool = false,
+ cursor_quad_alpha: f32 = 0,
scene_tex: ?*c.SDL_GPUTexture = null,
scene_tex_w: u32 = 0,
scene_tex_h: u32 = 0,
@@ -2648,6 +2654,8 @@ fn localSession(
.test_hz = if (std.c.getenv("PARDES_TEST_HZ")) |hz| @max(1, std.fmt.parseInt(u64, std.mem.span(hz), 10) catch 60) else 60,
};
defer shell.shutdownPtys();
+ // A virtual clock never blinks: goldens and feel series hold still.
+ g.no_blink = shell.test_clock != null;
const host = shell.host();
core.host = host;
@@ -4179,7 +4187,14 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
@intCast(@max(1, refreshNs(g) / std.time.ns_per_ms))
else
16;
- const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1;
+ const waited: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1;
+ // The cursor's blink is the shell's to time (Chrome.cursor_idle_ms):
+ // a frame a loop through an edge, else a wake at the next one; none
+ // while the window is minimized or unfocused. A virtual clock never
+ // blinks, so goldens hold still.
+ const blink = if (s.test_clock != null or minimized) null else blinkNow(g, if (s.surface) |frame| &frame.chrome else null);
+ const blink_ms: ?c_int = if (blink) |b| (if (b.edge) 1 else if (b.next_ns) |next| @as(c_int, @intCast(@min(std.math.maxInt(c_int), next / std.time.ns_per_ms + 1))) else null) else null;
+ const ms: c_int = if (blink_ms) |b| (if (waited < 0) b else @min(waited, b)) else waited;
// The wait is the 9P connections' turn with the core.
pardes.turn.rest();
const got = c.SDL_WaitEventTimeout(&sev, ms);
@@ -4189,6 +4204,9 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
// (PARDES_TEST_HZ), events or not, as a real display's present
// would; otherwise a wait that ran out moves it exactly to the
// core's wake.
+ // Through an edge, or woken for the next: the shell redraws the last
+ // frame with the new alpha, no core render (§5.5 level B).
+ if (blink_ms != null and (blink.?.edge or !got)) g.blink_due = true;
if (s.test_clock) |*virtual| {
if (core.continuous()) {
const display = virtual.* + std.time.ns_per_s / s.test_hz;
@@ -4273,6 +4291,27 @@ fn pollFrame(ctx: ?*anyopaque) void {
// early is on time: the wait that paces it is in whole milliseconds).
if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and
c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, s.gpa);
+ // A blink edge: the last frame again, the cursor's alpha moved on.
+ if (g.blink_due and !core.needs_frame and s.presented) if (s.surface) |frame| {
+ g.blink_due = false;
+ blink_redraws += 1;
+ s.presented = renderFrame(g, s.gpa, core, frame, core.settings.debug) catch false;
+ };
+}
+
+/// Frames drawn for a blink alone, with no core render (tests, tracing).
+var blink_redraws: u64 = 0;
+
+/// The blink now, from the frame's time since input and how long ago it
+/// arrived; null where nothing blinks (off, idle, an unfocused window).
+fn blinkNow(g: *const Gui, chrome: ?*const pardes.Chrome) ?pardes.animation.Blink {
+ const frame = chrome orelse return null;
+ if (g.no_blink or frame.cursor_glide & 2 == 0 or frame.cursor_idle_ms == std.math.maxInt(u32)) return null;
+ if (c.SDL_GetWindowFlags(g.window) & c.SDL_WINDOW_INPUT_FOCUS == 0) return null;
+ const since = @as(u64, frame.cursor_idle_ms) * std.time.ns_per_ms + (monoNs() -| g.frame_arrival_ns);
+ // Relative to now: `next_ns` is how long until the next edge.
+ const b = pardes.animation.Blink.at(since, 0);
+ return .{ .alpha = b.alpha, .edge = b.edge, .next_ns = if (b.next_ns) |next| next -| since else null };
}
/// One refresh of the display the window is on, in ns; a frame of the
@@ -4340,6 +4379,7 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
frame_started_ns = monoNs();
const frame = @constCast(surface);
s.surface = frame;
+ g.frame_arrival_ns = monoNs();
const scene_requested = g.post.ready();
if (!scene_requested) g.scene_failures = 0;
s.presented = renderFrame(
@@ -5359,8 +5399,13 @@ fn renderFrame(
const layout = fixedCellLayout(g);
const chrome = &surface.chrome;
var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff());
- // Cursors stand still while panes move, as they always have.
- const cursors_shown = groups.tracks == 0;
+ // Cursors stand still while panes move, as they always have; while the
+ // focused one glides or blinks, it is the overlay's quad instead. One
+ // or the other every frame (none only at a blink's off half).
+ const blink_alpha: f32 = if (blinkNow(g, chrome)) |b| b.alpha else 1;
+ const quad_mode = chrome.cursor_glide & 2 != 0 and (chrome.cursor_glide & 1 != 0 or blink_alpha < 1);
+ g.cursor_quad_alpha = if (quad_mode) chrome.cursor_alpha * blink_alpha else 0;
+ const cursors_shown = groups.tracks == 0 and !quad_mode;
var overlay_count = buildOverlay(g, core, surface, sw, sh, debug_on);
if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count))
overlay_count = 0;
@@ -8498,10 +8543,43 @@ fn buildOverlay(
if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = @floatFromInt(sw), .win_h = @floatFromInt(sh) };
appendWorkspacePet(g, core, surface, &builder);
+ appendCursorGlide(g, surface, &builder);
appendTouchOverlay(g, &builder, debug_on);
return @intCast(builder.len);
}
+/// G3: the focused cursor while it glides or blinks, its quad from the core
+/// (Chrome.cursor_quad, grid cells) in its cursor colour; a little less than
+/// solid, so the glyphs it passes over stay legible.
+fn appendCursorGlide(g: *Gui, surface: *const pardes.Surface, builder: *OverlayBuilder) void {
+ const chrome = &surface.chrome;
+ if (g.cursor_quad_alpha <= 0.001) return;
+ if (surface.panelTracks().len != 0) return;
+ const layout = fixedCellLayout(g);
+ var px: [4][2]f32 = undefined;
+ var centre: [2]f32 = .{ 0, 0 };
+ for (&px, 0..) |*point, i| {
+ point.* = .{ chrome.cursor_quad[i * 2] * layout.w, chrome.cursor_quad[i * 2 + 1] * layout.h };
+ centre[0] += point[0] / 4;
+ centre[1] += point[1] / 4;
+ }
+ // Its colour: the cursor the cells would draw nearest the quad.
+ var boxes: [max_cursors]CursorBox = undefined;
+ var rgb: [3]u8 = chrome.fg orelse fg_default;
+ var nearest: f32 = std.math.inf(f32);
+ for (boxes[0..cursorBoxes(g, surface, layout, builder.win_h, &boxes)]) |box| {
+ const dx = (box.x0 + box.x1) / 2 - centre[0];
+ const dy = (box.y0 + box.y1) / 2 - centre[1];
+ if (dx * dx + dy * dy < nearest) {
+ nearest = dx * dx + dy * dy;
+ rgb = box.rgb;
+ }
+ }
+ const color: OverlayColor = .{ .r = @as(f32, @floatFromInt(rgb[0])) / 255, .g = @as(f32, @floatFromInt(rgb[1])) / 255, .b = @as(f32, @floatFromInt(rgb[2])) / 255, .a = g.cursor_quad_alpha };
+ builder.addTri(px[0][0], px[0][1], px[1][0], px[1][1], px[2][0], px[2][1], color);
+ builder.addTri(px[0][0], px[0][1], px[2][0], px[2][1], px[3][0], px[3][1], color);
+}
+
fn appendWorkspacePet(g: *Gui, core: ?*const pardes.Pardes, surface: *const pardes.Surface, builder: *OverlayBuilder) void {
const p = core orelse return;
const kind: pet.Kind = @enumFromInt(@intFromEnum(p.settings.pet));
diff --git a/src/pardes.zig b/src/pardes.zig
index 6294b6cb..cbdc3037 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4260,6 +4260,12 @@ pub const Pardes = struct {
/// spring. `lift_serials` says whose it is, so a reused slot starts flat.
lifts: [MAX_PANES]animation.Spring = @splat(.{}),
lift_serials: [MAX_PANES]u32 = @splat(0),
+ /// The focused cursor's quad on its springs (animation.CursorGlide),
+ /// the pane and view origin it was last aimed in, and the last key or
+ /// click, which a blink holds for.
+ cursor_glide: animation.CursorGlide = .{},
+ cursor_view: struct { serial: u32 = 0, line: i64 = 0 } = .{},
+ input_ns: u64 = 0,
animate_theme_changes: bool = false,
native_images: bool = false,
/// The shell can take a changed image a few rows at a time (a texture row
@@ -5311,6 +5317,7 @@ pub const Pardes = struct {
}
switch (ev) {
.key, .mouse => {
+ if (ev == .key or ev.mouse.kind != .motion) p.input_ns = p.now_ns;
for (p.panes) |slot| {
if (slot) |pane| Messages.dismissMessage(p, pane);
}
@@ -6441,13 +6448,14 @@ pub const Pardes = struct {
return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null;
}
+
/// A render-time animation is moving (a pane's lift): it is sampled at
/// each frame's own `now`, so while it moves the shell draws every loop
/// and its present (vsync) paces it, at the display's rate. `nextWake`
/// is for the stepped animations, which count frames of `frame_ns`.
pub fn continuous(p: *const Pardes) bool {
for (p.lifts) |spring| if (!spring.settled) return true;
- return false;
+ return p.cursor_glide.moving();
}
/// For shells that only need a yes or no: macOS and the board, which
diff --git a/src/surface.zig b/src/surface.zig
index 15de128f..523cba43 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -412,6 +412,16 @@ pub const Chrome = struct {
grip_focus_ring: [3]u8 = .{ 0, 0, 0 },
/// A dirty pane's button fill.
grip_dirty: [3]u8 = .{ 0, 0, 0 },
+ /// The focused cursor's quad (four corners, x then y, in grid cells, top
+ /// left clockwise): `cursor_glide` bit 1 says there is one, bit 0 that
+ /// it glides, when a pixel shell draws it at `cursor_alpha` in place of
+ /// its cursors (as it does while it blinks, from `cursor_idle_ms`, the
+ /// time since the last key or click when the frame was made; maxInt,
+ /// no blink).
+ cursor_glide: u8 = 0,
+ cursor_alpha: f32 = 1,
+ cursor_quad: [8]f32 = @splat(0),
+ cursor_idle_ms: u32 = std.math.maxInt(u32),
/// The rule between a pane's tag and its body; null is halfway between
/// the tag's ground and the page the shell actually draws.
tag_rule: ?[3]u8 = null,