diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/animation.zig | 201 | ||||
| -rw-r--r-- | src/config.zig | 12 | ||||
| -rw-r--r-- | src/detached/wire.zig | 22 | ||||
| -rw-r--r-- | src/draw.zig | 163 | ||||
| -rw-r--r-- | src/gui/gui.zig | 84 | ||||
| -rw-r--r-- | src/pardes.zig | 10 | ||||
| -rw-r--r-- | src/surface.zig | 10 |
7 files changed, 494 insertions, 8 deletions
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, |
