summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig81
-rw-r--r--src/Presentation.zig13
-rw-r--r--src/animation.zig251
-rw-r--r--src/colors.zig8
-rw-r--r--src/detached/server.zig21
-rw-r--r--src/esp32p4.zig15
-rw-r--r--src/exec.zig4
-rw-r--r--src/gui/gui.zig86
-rw-r--r--src/host_io.zig17
-rw-r--r--src/macos.zig8
-rw-r--r--src/pardes.zig151
-rw-r--r--src/tty/tty.zig108
-rw-r--r--src/web.zig36
-rw-r--r--src/web/app.mjs36
14 files changed, 538 insertions, 297 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index 97a2f939..7334de95 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -272,11 +272,35 @@ fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool {
return false;
}
-/// Is any message line moving (and so owed ticks)?
-pub fn messagesAnimating(pane: *const Pane) bool {
- if (pane.msg_len > 0 and pane.msg_life.phase != .shown) return true;
- for (pane.msg_older[0..pane.msg_older_len]) |line| if (line.life.phase != .shown) return true;
- return false;
+/// Move every lingering message line `frames` along its linger at once:
+/// how `advance` jumps a wait instead of stepping it frame by frame.
+pub fn holdMessages(p: *Pardes, frames: u64) void {
+ const n: u16 = @intCast(@min(frames, std.math.maxInt(u16)));
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.msg_len > 0 and pane.msg_life.phase == .lingering) pane.msg_life.frame +|= n;
+ for (pane.msg_older[0..pane.msg_older_len]) |*line| {
+ if (line.life.phase == .lingering) line.life.frame +|= n;
+ }
+ }
+}
+
+/// Frames until this pane's message lines next change: 1 while a line
+/// falls in or dissolves, the rest of its linger while one only waits, null
+/// when every line just shows.
+pub fn messageWake(p: *const Pardes, pane: *const Pane) ?u64 {
+ var soonest: ?u64 = null;
+ const linger = messageFrames(p.settings.message_linger_ms);
+ for (0..@as(usize, pane.msg_older_len) + 1) |i| {
+ const life = if (i < pane.msg_older_len) pane.msg_older[i].life else if (pane.msg_len > 0) pane.msg_life else continue;
+ const frames: u64 = switch (life.phase) {
+ .shown => continue,
+ .entering, .leaving => 1,
+ .lingering => @max(1, linger -| life.frame),
+ };
+ soonest = if (soonest) |n| @min(n, frames) else frames;
+ }
+ return soonest;
}
/// How present a message is (1 = fully) and how far above its row it
@@ -286,7 +310,7 @@ pub const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 };
/// Animation frames a message timing lasts; at least one.
pub fn messageFrames(ms: u16) u16 {
- return @intCast(@max(1, (@as(u32, ms) + animation.Animation.frame_ms - 1) / animation.Animation.frame_ms));
+ return @intCast(@max(1, (@as(u32, ms) + animation.frame_ms - 1) / animation.frame_ms));
}
pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
@@ -792,3 +816,48 @@ test "notices stack over the body's top rows without moving them" {
for (p.surface.tagLayers()) |*layer|
try std.testing.expect(layer.cols == 0 or layer.kind != .notice);
}
+
+test "an idle core wants no wake and a lingering message sleeps to its linger's end" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const frame = animation.frame_ns;
+ p.advance(1_000_000);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+ const pane = p.panes[p.active].?;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
+ // Falling in: a frame from now.
+ try std.testing.expectEqual(@as(?u64, p.stepped_ns + frame), p.nextWake());
+ const fall = messageFrames(p.settings.message_fall_ms);
+ p.advance(p.stepped_ns + fall * frame);
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+ // A key starts the 800 ms linger: the core sleeps to its end, not a
+ // frame at a time, and the frames it jumps draw nothing.
+ p.update(.{ .key = .{ .cp = 'x' } });
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ const linger = messageFrames(p.settings.message_linger_ms);
+ const due = p.stepped_ns + linger * frame;
+ try std.testing.expectEqual(@as(?u64, due), p.nextWake());
+ p.needs_frame = false;
+ p.advance(due - frame);
+ try std.testing.expectEqual(@as(?u64, due), p.nextWake());
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ p.advance(due);
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
+ try std.testing.expectEqual(@as(?u64, due + frame), p.nextWake());
+ p.advance(due + 10 * std.time.ns_per_s);
+ try std.testing.expectEqual(@as(usize, 0), pane.msg_len);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+
+ // A long linger ends when it says, jumped in one step, not stretched by
+ // the cap on catching up with moving frames.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 5000"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
+ p.advance(p.stepped_ns + fall * frame);
+ p.update(.{ .key = .{ .cp = 'x' } });
+ const long_due = p.stepped_ns + messageFrames(5000) * frame;
+ try std.testing.expectEqual(@as(?u64, long_due), p.nextWake());
+ p.advance(long_due);
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
+}
diff --git a/src/Presentation.zig b/src/Presentation.zig
index fe8317dd..45b196c8 100644
--- a/src/Presentation.zig
+++ b/src/Presentation.zig
@@ -295,11 +295,18 @@ pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void
self.tracks = @splat(null);
self.closing_len = 0;
self.diff_state = .none;
- self.capturePrevious(p.gpa, &p.surface);
+ // The frozen grid exists for transitions alone: with them off, an
+ // idle frame copies nothing.
+ if (p.settings.panel_transition != .off) {
+ self.capturePrevious(p.gpa, &p.surface);
+ } else {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ }
}
}
-fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
+pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
self.diff_state = .none;
const cells = surface.cells;
if (cells.len == 0) {
@@ -651,6 +658,8 @@ test "presentation submission failure preserves shown geometry until retry is ac
defer p.deinit();
var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer frame.deinit();
+ // The frozen grid is kept only while a transition is chosen.
+ p.settings.panel_transition = .slide;
_ = try p.render(frame.allocator());
p.acknowledgePanelPresentation(&.{});
const shown = p.presentation.shown;
diff --git a/src/animation.zig b/src/animation.zig
index b190c1a1..d6ef1ec3 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -588,157 +588,158 @@ test "dissolve has exact stable endpoints" {
}
}
-pub const Animation = struct {
- pub const frame_ms: u32 = 16;
- pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
- pub const transition_steps: u16 = 10;
+pub const frame_ms: u32 = 16;
+pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
+pub const transition_steps: u16 = 10;
- pub fn Transition(comptime Value: type) type {
- return struct {
- const Self = @This();
+/// 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 {
+ return struct {
+ const Self = @This();
- from: Value,
- to: Value,
- displayed: Value,
- step: u16 = transition_steps,
+ from: Value,
+ to: Value,
+ displayed: Value,
+ step: u16 = transition_steps,
- pub fn init(value: Value) Self {
- return .{ .from = value, .to = value, .displayed = value };
- }
-
- pub fn isActive(a: *const Self) bool {
- return a.step < transition_steps;
- }
+ pub fn init(value: Value) Self {
+ return .{ .from = value, .to = value, .displayed = value };
+ }
- pub fn retarget(a: *Self, target: Value) void {
- a.from = a.displayed;
- a.to = target;
- a.step = if (std.meta.eql(a.from, target)) transition_steps else 0;
- if (a.step == transition_steps) a.displayed = target;
- }
+ pub fn isActive(a: *const Self) bool {
+ return a.step < transition_steps;
+ }
- pub fn advance(a: *Self) void {
- if (!a.isActive()) return;
- a.step += 1;
- a.displayed = if (a.step == transition_steps)
- a.to
- else
- Value.interpolate(a.from, a.to, a.step, transition_steps);
- }
+ pub fn retarget(a: *Self, target: Value) void {
+ a.from = a.displayed;
+ a.to = target;
+ a.step = if (std.meta.eql(a.from, target)) transition_steps else 0;
+ if (a.step == transition_steps) a.displayed = target;
+ }
- pub fn snap(a: *Self, value: Value) void {
- a.* = init(value);
- }
- };
- }
+ pub fn advance(a: *Self) void {
+ if (!a.isActive()) return;
+ a.step += 1;
+ a.displayed = if (a.step == transition_steps)
+ a.to
+ else
+ Value.interpolate(a.from, a.to, a.step, transition_steps);
+ }
- pub fn Immediate(comptime Value: type) type {
- return struct {
- const Self = @This();
+ pub fn snap(a: *Self, value: Value) void {
+ a.* = init(value);
+ }
+ };
+}
- displayed: Value,
+pub fn Immediate(comptime Value: type) type {
+ return struct {
+ const Self = @This();
- pub fn init(value: Value) Self {
- return .{ .displayed = value };
- }
+ displayed: Value,
- pub fn isActive(_: *const Self) bool {
- return false;
- }
+ pub fn init(value: Value) Self {
+ return .{ .displayed = value };
+ }
- pub fn retarget(a: *Self, target: Value) void {
- a.displayed = target;
- }
+ pub fn isActive(_: *const Self) bool {
+ return false;
+ }
- pub fn advance(_: *Self) void {}
+ pub fn retarget(a: *Self, target: Value) void {
+ a.displayed = target;
+ }
- pub fn snap(a: *Self, value: Value) void {
- a.displayed = value;
- }
- };
- }
+ pub fn advance(_: *Self) void {}
- pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 {
- if (step == 0) return from;
- if (step >= steps) return to;
- var out: [3]u8 = undefined;
- for (&out, from, to) |*dst, a, b| {
- const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step;
- dst.* = @intCast((numerator + steps / 2) / steps);
+ pub fn snap(a: *Self, value: Value) void {
+ a.displayed = value;
}
- return out;
+ };
+}
+
+pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 {
+ if (step == 0) return from;
+ if (step >= steps) return to;
+ var out: [3]u8 = undefined;
+ for (&out, from, to) |*dst, a, b| {
+ const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step;
+ dst.* = @intCast((numerator + steps / 2) / steps);
}
+ return out;
+}
- const TestColor = struct {
- rgb: [3]u8,
+const TestColor = struct {
+ rgb: [3]u8,
- pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor {
- return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) };
- }
- };
+ pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor {
+ return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) };
+ }
+};
- test "Immediate lands where a completed Transition lands" {
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
+test "Immediate lands where a completed Transition lands" {
+ const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
+ const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
- var faded = Animation.Transition(TestColor).init(from);
- faded.retarget(to);
- for (0..transition_steps) |_| faded.advance();
+ var faded = Fade(TestColor).init(from);
+ faded.retarget(to);
+ for (0..transition_steps) |_| faded.advance();
- var instant = Immediate(TestColor).init(from);
- try std.testing.expect(!instant.isActive());
- instant.retarget(to);
- try std.testing.expectEqual(faded.displayed, instant.displayed);
+ var instant = Immediate(TestColor).init(from);
+ try std.testing.expect(!instant.isActive());
+ instant.retarget(to);
+ try std.testing.expectEqual(faded.displayed, instant.displayed);
- // Never active, so a frontend that renders only while something is animating stops immediately
- // rather than spending ten frames discovering there is nothing to draw.
- try std.testing.expect(!instant.isActive());
- instant.advance();
- try std.testing.expectEqual(to, instant.displayed);
+ // Never active, so a frontend that renders only while something is animating stops immediately
+ // rather than spending ten frames discovering there is nothing to draw.
+ try std.testing.expect(!instant.isActive());
+ instant.advance();
+ try std.testing.expectEqual(to, instant.displayed);
- instant.snap(from);
- try std.testing.expectEqual(from, instant.displayed);
- }
+ instant.snap(from);
+ try std.testing.expectEqual(from, instant.displayed);
+}
- test "fixed-step interpolation has exact monotonic endpoints" {
- const Tween = Animation.Transition(TestColor);
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
- var tween = Tween.init(from);
- tween.retarget(to);
- try std.testing.expectEqual(from, tween.displayed);
+test "fixed-step interpolation has exact monotonic endpoints" {
+ const Tween = Fade(TestColor);
+ const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
+ const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
+ var tween = Tween.init(from);
+ tween.retarget(to);
+ try std.testing.expectEqual(from, tween.displayed);
- var previous = tween.displayed;
- for (0..transition_steps) |_| {
- tween.advance();
- try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]);
- try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]);
- try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]);
- previous = tween.displayed;
- }
- try std.testing.expect(!tween.isActive());
- try std.testing.expectEqual(to, tween.displayed);
+ var previous = tween.displayed;
+ for (0..transition_steps) |_| {
tween.advance();
- try std.testing.expectEqual(to, tween.displayed);
+ try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]);
+ try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]);
+ try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]);
+ previous = tween.displayed;
}
+ try std.testing.expect(!tween.isActive());
+ try std.testing.expectEqual(to, tween.displayed);
+ tween.advance();
+ try std.testing.expectEqual(to, tween.displayed);
+}
- test "retarget starts at the currently displayed value" {
- const Tween = Animation.Transition(TestColor);
- const first: TestColor = .{ .rgb = .{ 0, 40, 200 } };
- const second: TestColor = .{ .rgb = .{ 200, 140, 0 } };
- const third: TestColor = .{ .rgb = .{ 20, 10, 250 } };
- var tween = Tween.init(first);
- tween.retarget(second);
- tween.advance();
- tween.advance();
- tween.advance();
- const on_screen = tween.displayed;
+test "retarget starts at the currently displayed value" {
+ const Tween = Fade(TestColor);
+ const first: TestColor = .{ .rgb = .{ 0, 40, 200 } };
+ const second: TestColor = .{ .rgb = .{ 200, 140, 0 } };
+ const third: TestColor = .{ .rgb = .{ 20, 10, 250 } };
+ var tween = Tween.init(first);
+ tween.retarget(second);
+ tween.advance();
+ tween.advance();
+ tween.advance();
+ const on_screen = tween.displayed;
+
+ tween.retarget(third);
+ try std.testing.expectEqual(on_screen, tween.from);
+ try std.testing.expectEqual(on_screen, tween.displayed);
+ try std.testing.expect(tween.isActive());
+ for (0..transition_steps) |_| tween.advance();
+ try std.testing.expectEqual(third, tween.displayed);
+}
- tween.retarget(third);
- try std.testing.expectEqual(on_screen, tween.from);
- try std.testing.expectEqual(on_screen, tween.displayed);
- try std.testing.expect(tween.isActive());
- for (0..transition_steps) |_| tween.advance();
- try std.testing.expectEqual(third, tween.displayed);
- }
-};
diff --git a/src/colors.zig b/src/colors.zig
index f814d55d..0065d5ef 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -186,15 +186,15 @@ pub const ChromeTheme = struct {
pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme {
var out: ChromeTheme = undefined;
inline for (@typeInfo(ChromeTheme).@"struct".fields) |field|
- @field(out, field.name) = animation.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
+ @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
return out;
}
};
pub const ChromeAnimation = if (theme_animation)
- animation.Animation.Transition(ChromeTheme)
+ animation.Fade(ChromeTheme)
else
- animation.Animation.Immediate(ChromeTheme);
+ animation.Immediate(ChromeTheme);
pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]);
@@ -414,7 +414,7 @@ test "runtime theme changes animate chrome and retarget without a jump" {
try std.testing.expectEqualStrings("acme", p.theme().name);
try std.testing.expectEqual(midflight, p.chromeTheme().*);
try std.testing.expect(p.animationActive());
- for (0..animation.Animation.transition_steps) |_| p.update(.tick);
+ for (0..animation.transition_steps) |_| p.update(.tick);
try std.testing.expect(!p.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
// Extra ticks are inert at the exact endpoint.
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 5c0e1487..212c9a3d 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -140,6 +140,8 @@ pub const Session = struct {
worker_gpa: std.mem.Allocator,
io: std.Io,
core: *pardes.Pardes,
+ /// PARDES_TEST_CLOCK's virtual time (host_io.testClock).
+ test_clock: ?u64 = null,
listener: c_int = -1,
path_buf: [sun_path_len]u8 = undefined,
path_len: usize = 0,
@@ -343,8 +345,14 @@ pub const Session = struct {
return @ptrCast(@alignCast(ctx.?));
}
+ fn clockNow(ctx: ?*anyopaque) u64 {
+ const s = of(ctx);
+ return s.test_clock orelse host_io.monotonicNs();
+ }
+
const vtable: host_io.Host.VTable = .{
.wait_input = waitInput,
+ .now = clockNow,
.present = present,
.poll_frame = pollFrame,
.spawn = spawn,
@@ -827,8 +835,9 @@ pub const Session = struct {
pardes.turn.rest();
nap(if (timeout_ms == 0) 16 else timeout_ms);
pardes.turn.wake();
- // An animating frame's wait is the frame: tick it.
- if (timeout_ms != 0) s.core.update(.tick);
+ if (timeout_ms != 0) if (s.test_clock) |*virtual| {
+ virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*);
+ };
return;
}
var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int)));
@@ -838,9 +847,10 @@ pub const Session = struct {
const ready = libc.poll(&fds, @intCast(n), timeout);
pardes.turn.wake();
if (ready > 0) s.dispatch(fds[0..n], src[0..n]);
- // Animating, the frame's time is up: advance it. There is no other
- // tick in a detached session, and without it an animation never ends.
- if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) s.core.update(.tick);
+ // The core's wake ran out; the pump advances it from `now`.
+ if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) if (s.test_clock) |*virtual| {
+ virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*);
+ };
_ = s.drainCompletions(true);
s.expire(monotonicMs());
s.harvest();
@@ -1155,6 +1165,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void
.cols = options.cols,
.rows = options.rows,
.prompt_rcs = host_io.Shell.prepare(),
+ .test_clock = host_io.testClock(),
};
defer session.core.deinit();
defer session.deinit();
diff --git a/src/esp32p4.zig b/src/esp32p4.zig
index 0d532e1e..c583de54 100644
--- a/src/esp32p4.zig
+++ b/src/esp32p4.zig
@@ -572,10 +572,9 @@ export fn pardes_esp32p4_tick(now_ms: u64) callconv(.c) void {
dirty = true;
}
}
- if (c.animationActive()) {
- c.update(.tick);
- dirty = true;
- }
+ // The core steps its own animations by this clock (`Host.now`).
+ c.advance(now_ms * std.time.ns_per_ms);
+ if (c.needs_frame) dirty = true;
}
/// How long a lone ESC waits for a second byte before it counts as the Escape key.
@@ -590,7 +589,7 @@ var esc_held_at: ?u64 = null;
export fn pardes_esp32p4_wants_frame() callconv(.c) bool {
const c = core orelse return false;
- return dirty or c.animationActive();
+ return dirty or c.needs_frame;
}
export fn pardes_esp32p4_render() callconv(.c) u32 {
@@ -607,7 +606,11 @@ export fn pardes_esp32p4_quit() callconv(.c) bool {
// ------------------------------------------------------------------------------------ the host
-const pardes_host: pardes.Host.VTable = .{ .present = present, .gpio_toggle = gpioToggle };
+const pardes_host: pardes.Host.VTable = .{ .now = boardNow, .present = present, .gpio_toggle = gpioToggle };
+
+fn boardNow(_: ?*anyopaque) u64 {
+ return last_now_ms * std.time.ns_per_ms;
+}
/// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and
/// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message
diff --git a/src/exec.zig b/src/exec.zig
index b16c600c..ad0f7b9b 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -502,6 +502,10 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
if (p.settings.column_tags != previous_column_tags) {
if (p.header_column != null) tagline.exitHeader(p);
}
+ // Idle frames keep no frozen grid while transitions are off; the one
+ // just drawn is the baseline the first transition starts from.
+ if (transition_changed and previous_transition == .off)
+ p.presentation.capturePrevious(p.gpa, &p.surface);
if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) {
p.abandonPanelAnimations();
}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 0737bb63..a1e21dc0 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -200,55 +200,18 @@ const touch_click_flash_max_frames: u8 = 14;
const touch_click_flash_vertices: usize = 1400;
const touch_scroll_tick: f32 = 0.02;
-const AnimationClock = struct {
- next_ns: u64 = 0,
-
- fn due(clock: *AnimationClock, active: bool, now_ns: u64) bool {
- if (!active) {
- clock.next_ns = 0;
- return false;
- }
- if (clock.next_ns == 0) {
- clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns;
- return false;
- }
- if (now_ns < clock.next_ns) return false;
- clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns;
- return true;
- }
-};
-
-fn finishPresentedAnimationFrame(
- clock: *AnimationClock,
- core: *pardes.Pardes,
- tracks: []const pardes.animation.Track,
- now_ns: u64,
-) void {
- core.acknowledgePanelPresentation(tracks);
- if (clock.due(core.animationActive(), now_ns)) core.update(.tick);
-}
-
-test "GUI animation clock is active-only and cadence gated" {
- var clock: AnimationClock = .{};
- try std.testing.expect(!clock.due(false, 100));
- try std.testing.expect(!clock.due(true, 100));
- try std.testing.expect(!clock.due(true, 100 + pardes.animation.Animation.frame_ns - 1));
- try std.testing.expect(clock.due(true, 100 + pardes.animation.Animation.frame_ns));
- try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.Animation.frame_ns));
- try std.testing.expectEqual(@as(u64, 0), clock.next_ns);
-}
-
test "a persistent scene presents a new panel's frame zero before advancing it" {
const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer core.deinit();
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
- var clock: AnimationClock = .{};
+ const frame = pardes.animation.frame_ns;
core.settings.scene_effects.crt = true;
+ core.advance(100);
const scene_frame = try core.render(arena.allocator());
- finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100);
- try std.testing.expectEqual(@as(u64, 100 + pardes.animation.Animation.frame_ns), clock.next_ns);
+ core.acknowledgePanelPresentation(scene_frame.panelTracks());
+ try std.testing.expectEqual(@as(?u64, 100 + frame), core.nextWake());
core.settings.panel_transition = .slide;
core.update(.{ .command = "Newcol" });
@@ -258,12 +221,12 @@ test "a persistent scene presents a new panel's frame zero before advancing it"
try std.testing.expect(track_count > 0);
for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame);
- finishPresentedAnimationFrame(
- &clock,
- core,
- first.panelTracks(),
- 100 + pardes.animation.Animation.frame_ns,
- );
+ core.acknowledgePanelPresentation(first.panelTracks());
+ // Just short of a frame is no frame at all.
+ core.advance(100 + frame - 1);
+ _ = arena.reset(.retain_capacity);
+ for ((try core.render(arena.allocator())).panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame);
+ core.advance(100 + frame);
_ = arena.reset(.retain_capacity);
const second = try core.render(arena.allocator());
try std.testing.expectEqual(track_count, second.panelTracks().len);
@@ -3742,7 +3705,6 @@ const Shell = struct {
presented: bool = false,
skipped_presents: u8 = 0,
surface: ?*pardes.Surface = null,
- animation_clock: AnimationClock = .{},
saw_event: bool = false,
dump_err: ?anyerror = null,
retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}),
@@ -3832,6 +3794,7 @@ const Shell = struct {
const vtable: pardes.Host.VTable = .{
.wait_input = waitInput,
+ .now = guiNow,
.present = present,
.post_present = postPresent,
.poll_frame = pollFrame,
@@ -3857,6 +3820,7 @@ const Shell = struct {
const grid_vtable: pardes.Host.VTable = vt: {
var v = vtable;
v.wait_input = null;
+ v.now = null;
v.poll_frame = gridPollFrame;
v.present = gridPresent;
v.post_present = gridPostPresent;
@@ -4106,20 +4070,16 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
fn postPresent(ctx: ?*anyopaque) void {
const s = shellOf(ctx);
const g = s.gui orelse return;
- if (!s.presented) {
- // No image (a minimized window): time still passes, so a lingering
- // message or a hover wait runs out instead of redrawing forever.
- if (s.animation_clock.due(s.core.animationActive(), c.SDL_GetTicksNS())) s.core.update(.tick);
- return;
- }
+ // No image (a minimized window) shows no tracks; time still passes and
+ // the pump advances it all the same.
+ if (!s.presented) return;
const frame = s.surface orelse return;
refreshPresentedPointer(g, s.core);
- finishPresentedAnimationFrame(
- &s.animation_clock,
- s.core,
- frame.panelTracks(),
- c.SDL_GetTicksNS(),
- );
+ s.core.acknowledgePanelPresentation(frame.panelTracks());
+}
+
+fn guiNow(_: ?*anyopaque) u64 {
+ return c.SDL_GetTicksNS();
}
fn gridPollFrame(ctx: ?*anyopaque) void {
@@ -4129,8 +4089,10 @@ fn gridPollFrame(ctx: ?*anyopaque) void {
if (f.tick().count != 0) s.saw_event = true;
}
pollCwds(s.core, s.ptys);
- if (s.core.animationActive()) {
- s.core.update(.tick);
+ // Grid time is virtual: each poll moves it straight to the core's
+ // next wake, a frame at a time while anything moves.
+ if (s.core.nextWake()) |due| {
+ s.core.advance(due);
s.saw_event = true;
}
}
diff --git a/src/host_io.zig b/src/host_io.zig
index 6009c00f..f1a4eb98 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -12,6 +12,9 @@ pub const Host = struct {
pub const VTable = struct {
wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null,
+ /// The shell's monotonic clock in ns: the only time the core sees.
+ /// Without one, core animation moves only by `.tick` events.
+ now: ?*const fn (ctx: ?*anyopaque) u64 = null,
present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null,
post_present: ?*const fn (ctx: ?*anyopaque) void = null,
poll_frame: ?*const fn (ctx: ?*anyopaque) void = null,
@@ -39,6 +42,20 @@ pub const Host = struct {
};
};
+/// The shells' monotonic clock in ns, the one `Host.now` answers with.
+/// PARDES_TEST_CLOCK makes a shell answer a virtual clock instead, moved
+/// only when a timed wait runs out -- straight to the core's next wake -- so
+/// the snapshot harness sees the same frames whatever the machine's load.
+pub fn monotonicNs() u64 {
+ var ts: libc.timespec = undefined;
+ if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec));
+}
+
+pub fn testClock() ?u64 {
+ return if (libc.getenv("PARDES_TEST_CLOCK")) |v| (if (std.mem.span(v).len != 0) 0 else null) else null;
+}
+
pub const Fallback = struct {
pub const dump_path = "pardes.dump.zon";
diff --git a/src/macos.zig b/src/macos.zig
index 854b24f8..fce0a53e 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -118,7 +118,7 @@ const scene_frame_hz: u32 = 60;
const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s;
-const max_tick_catch_up_ns: u64 = 4 * pardes.animation.Animation.frame_ns;
+const max_tick_catch_up_ns: u64 = 4 * pardes.animation.frame_ns;
const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES);
const watch_slot_count = pardes.MAX_PANES + 1;
@@ -870,18 +870,18 @@ const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 };
fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend {
const measured = if (previous_ns == 0 or now_ns <= previous_ns)
- pardes.animation.Animation.frame_ns
+ pardes.animation.frame_ns
else
now_ns - previous_ns;
const elapsed = @min(measured, max_tick_catch_up_ns);
var bank = bank_ns +| elapsed;
var steps: u32 = 0;
- while (bank >= pardes.animation.Animation.frame_ns) : (steps += 1) bank -= pardes.animation.Animation.frame_ns;
+ while (bank >= pardes.animation.frame_ns) : (steps += 1) bank -= pardes.animation.frame_ns;
return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank };
}
test "the animation clock spends real time, not callbacks" {
- const frame = pardes.animation.Animation.frame_ns;
+ const frame = pardes.animation.frame_ns;
const expectEqual = std.testing.expectEqual;
const first = spendTickTime(0, 999 * std.time.ns_per_s, 0);
diff --git a/src/pardes.zig b/src/pardes.zig
index bfd8a4f6..161a047c 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3337,9 +3337,9 @@ test "vertical close samples only a canonical baseline containing that pane" {
defer p.deinit();
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
@@ -3360,9 +3360,9 @@ test "vertical close samples only a canonical baseline containing that pane" {
defer p.deinit();
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
@@ -3386,9 +3386,9 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch
defer p.deinit();
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
@@ -3875,6 +3875,13 @@ pub const Pardes = struct {
/// Set by a shell whose present was skipped (no swapchain image): the
/// frame is still owed, and the next pump draws it again.
present_skipped: bool = false,
+ /// Core time. `now_ns` is the shell's monotonic clock as `advance` was
+ /// last given it; `stepped_ns` is how far animation has been stepped
+ /// along it, in whole frames of `animation.frame_ns`. Idle, the two are
+ /// the same instant.
+ now_ns: u64 = 0,
+ stepped_ns: u64 = 0,
+ clock_started: bool = false,
/// Builtins announce themselves on the message row. Off while startup
/// configuration is applied: those lines are nobody clicking, and should
/// not leave the row holding the name of the last one.
@@ -4647,7 +4654,17 @@ pub const Pardes = struct {
pub fn pump(p: *Pardes, h: Host) !void {
p.host = h;
const v = h.vtable;
- if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) animation.Animation.frame_ms else 0);
+ if (v.wait_input) |f| {
+ // Asleep until input or until core animation next needs a
+ // frame; 0 is no timeout at all.
+ const timeout: u32 = if (p.nextWake()) |due| wake: {
+ const now = if (v.now) |clock| clock(h.ctx) else p.now_ns;
+ const ms = ((due -| now) + std.time.ns_per_ms - 1) / std.time.ns_per_ms;
+ break :wake @intCast(std.math.clamp(ms, 1, std.math.maxInt(u32)));
+ } else 0;
+ f(h.ctx, timeout);
+ }
+ if (v.now) |clock| p.advance(clock(h.ctx));
while (p.nextQueued()) |ev| p.update(ev);
while (p.nextEffect()) |e| p.perform(e);
turn.settle();
@@ -4657,7 +4674,9 @@ pub const Pardes = struct {
// Nothing to draw: do not spend a frame, and above all do not block on
// the display for one. This is what keeps the frame rate governing
// rendering and nothing else.
- if (!p.needs_frame and !p.animationActive()) return;
+ // A shell with a clock draws what `advance` stepped; one without
+ // steps its own `.tick`s and draws while anything animates.
+ if (!p.needs_frame and (v.now != null or !p.animationActive())) return;
_ = p.frame_arena.reset(.retain_capacity);
const surface = try p.render(p.frame_arena.allocator());
if (v.present) |f| f(h.ctx, surface);
@@ -5717,18 +5736,85 @@ pub const Pardes = struct {
return &p.chrome_animation.displayed;
}
- /// Whether an idle frontend should schedule another tick. All frame-based
- /// core work belongs in this one predicate so TTY and GUI mature it alike.
- pub fn animationActive(p: *const Pardes) bool {
- if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true;
+ /// When core animation next needs a frame, on the `stepped_ns`
+ /// timeline: a frame from now while anything moves, the end of the
+ /// wait while something only waits (a message lingering, a look-hover
+ /// delay), null when nothing is animating. Every frame-based animation
+ /// the core owns is in this one list, which is what lets every shell
+ /// sleep exactly as long as nothing changes.
+ pub fn nextWake(p: *const Pardes) ?u64 {
+ const next = p.stepped_ns + animation.frame_ns;
+ if (p.chrome_animation.isActive() or p.presentation.animating()) return next;
+ const scene = p.settings.scene_effects;
+ if (scene.crt or scene.ripple or scene.glitch) return next;
+ var frames: ?u64 = null;
+ if (p.look_hover_wait) |waiting| {
+ const delay = config.look_preview_delay_frames orelse return next;
+ frames = @max(1, delay -| waiting.frames);
+ }
for (p.panes) |slot| if (slot) |pane| {
- if (Messages.messagesAnimating(pane)) return true;
+ const wait = Messages.messageWake(p, pane) orelse continue;
+ frames = if (frames) |soonest| @min(soonest, wait) else wait;
};
- const scene = p.settings.scene_effects;
- if (scene.crt or scene.ripple or scene.glitch) return true;
- return p.presentation.animating();
+ return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null;
+ }
+
+ /// For shells that only need a yes or no: macOS and the board, which
+ /// keep their own frame banks.
+ pub fn animationActive(p: *const Pardes) bool {
+ return p.nextWake() != null;
}
+ /// Bring core animation up to `now_ns`, the shell's monotonic clock: one
+ /// `.tick` for every whole frame that has passed since it last stood
+ /// still, so an animation takes as long on a 144 Hz display, over ssh or
+ /// after a slow frame as it does anywhere. A wait (a message lingering,
+ /// the look-hover delay) is jumped to its end in one step and drawn
+ /// once. Idle, time is not banked: the next animation starts from now.
+ pub fn advance(p: *Pardes, now_ns: u64) void {
+ // The first time a shell's clock is seen, core time starts there.
+ const started = p.clock_started;
+ p.clock_started = true;
+ p.now_ns = now_ns;
+ if (!started) p.stepped_ns = now_ns;
+ const frame = animation.frame_ns;
+ var moving: u32 = 0;
+ while (p.nextWake()) |due| {
+ if (due > now_ns) break;
+ // Only waiting until `due`: its counters move there at once.
+ const held = (due - p.stepped_ns) / frame;
+ if (held > 1) {
+ Messages.holdMessages(p, held - 1);
+ if (p.look_hover_wait) |*waiting| waiting.frames +|= @intCast(@min(held - 1, std.math.maxInt(u16)));
+ p.stepped_ns += (held - 1) * frame;
+ }
+ // Back from a stall, the moving frames it missed are not all
+ // run at once: the rest are stepped from now, at their pace.
+ if (moving == max_catch_up_frames) {
+ p.stepped_ns = now_ns;
+ return;
+ }
+ moving += 1;
+ p.stepped_ns += frame;
+ p.update(.tick);
+ // A stepped frame is a frame to draw. (A `.tick` does not ask
+ // for one itself: the last frame of a fade used never to be
+ // drawn.)
+ p.needs_frame = true;
+ } else {
+ p.stepped_ns = now_ns;
+ return;
+ }
+ // A 60 Hz display frame is 16.67 ms and ours 16: without this, every
+ // 24th display frame would take two steps, a hitch in every fade.
+ // An overshoot this small is let go; a larger one (144 Hz lands 4.8
+ // ms past) is kept, so the average rate stays right.
+ if (moving > 0 and now_ns - p.stepped_ns < vsync_slack_ns) p.stepped_ns = now_ns;
+ }
+
+ pub const vsync_slack_ns = 1500 * std.time.ns_per_us;
+ pub const max_catch_up_frames = 240;
+
pub fn noteHaptic(p: *Pardes, pulse: Haptic) void {
if (comptime platform != .macos) return;
if (p.haptic == .look) return;
@@ -6751,9 +6837,9 @@ test "board heap: transition grids fit the budget and the surface borrows its sn
defer p.deinit();
var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
p.sync();
@@ -6864,3 +6950,40 @@ test "jump history terminal rectangle follows output and clears on reflow" {
try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null);
try std.testing.expectEqual(.none, pane.sel[0].state);
}
+
+test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const frame = animation.frame_ns;
+ // Something that animates for as long as it is on.
+ p.settings.scene_effects.crt = true;
+ for ([_]u64{ 60, 120, 144 }) |hz| {
+ const start: u64 = 5 * std.time.ns_per_s;
+ p.clock_started = false;
+ p.advance(start);
+ var steps: u64 = 0;
+ var at: u64 = 1;
+ while (at <= hz) : (at += 1) {
+ const before = p.stepped_ns;
+ p.advance(start + at * std.time.ns_per_s / hz);
+ const taken = (p.stepped_ns - before + frame / 2) / frame;
+ // Never two steps in one display frame: that is a visible hitch.
+ try std.testing.expect(taken <= 1);
+ steps += taken;
+ }
+ switch (hz) {
+ // One step a display frame, every display frame.
+ 60 => try std.testing.expectEqual(@as(u64, 60), steps),
+ 120 => try std.testing.expectEqual(@as(u64, 60), steps),
+ // The frame's own rate: 62 a second, give or take one.
+ else => try std.testing.expect(steps >= 61 and steps <= 63),
+ }
+ }
+ // A stall of minutes is not minutes of frames.
+ const before = p.stepped_ns;
+ p.advance(before + 600 * std.time.ns_per_s);
+ try std.testing.expectEqual(before + 600 * std.time.ns_per_s, p.stepped_ns);
+ p.settings.scene_effects.crt = false;
+ p.advance(p.stepped_ns + frame);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 66e551b1..6083f609 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -76,7 +76,8 @@ pub const Command = struct {
pub var value: union(enum) {
nop,
quit,
- tick,
+ /// A timed wait ran out: the core's next wake has come.
+ wake,
key_press: vaxis.Key,
pty_read: struct { id: usize, gen: u32, bytes: []u8 },
pty_eof: struct { id: usize, gen: u32 },
@@ -728,6 +729,7 @@ fn localSession(
.paste_buf = paste_buf,
.inotify_fd = file_watch.init(false),
.fs = fs,
+ .test_clock = host_io.testClock(),
};
pardes.lsp.setStatusSink(&sh, lspStatusSink);
defer {
@@ -775,7 +777,7 @@ fn localSession(
try startInput(loop, input_cache);
defer if (attached.* == null) stopInput(loop);
(try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach();
- (try std.Thread.spawn(.{}, tickWatch, .{ io, loop })).detach();
+ (try std.Thread.spawn(.{}, wakeWatch, .{ io, loop })).detach();
if (fs) |f| try f.watch(loop, wakeFs);
try vx.queryTerminalSend(tty.writer());
@@ -900,6 +902,10 @@ const Shell = struct {
check_files: bool = false,
in_paste: bool = false,
tracks: []const pardes.animation.Track = &.{},
+ /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), and where the
+ /// running-out wait moves it: the core's next wake.
+ test_clock: ?u64 = null,
+ wake_to: u64 = 0,
fn of(ctx: ?*anyopaque) *Shell {
return @ptrCast(@alignCast(ctx.?));
@@ -911,12 +917,14 @@ const Shell = struct {
const isolated_vtable: host_io.Host.VTable = .{
.wait_input = waitInput,
+ .now = clockNow,
.present = present,
.post_present = postPresent,
};
const vtable: host_io.Host.VTable = .{
.wait_input = waitInput,
+ .now = clockNow,
.present = present,
.post_present = postPresent,
.poll_frame = pollFrame,
@@ -939,12 +947,20 @@ const Shell = struct {
.pipe = pipe,
};
+ fn clockNow(ctx: ?*anyopaque) u64 {
+ const s = of(ctx);
+ return s.test_clock orelse host_io.monotonicNs();
+ }
+
fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
const s = of(ctx);
var batch: usize = 0;
- // The wait is the 9P connections' turn with the core. Animating, the
- // frame's tick arrives through the same queue (`tickWatch`).
- if (timeout_ms != 0) tick_request.set(s.io);
+ // The wait is the 9P connections' turn with the core. A timeout (the
+ // core's next wake) arrives through the same queue (`wakeWatch`).
+ if (timeout_ms != 0) {
+ s.wake_to = s.core.nextWake() orelse 0;
+ requestWake(s.io, timeout_ms);
+ }
pardes.turn.rest();
const first = s.loop.nextEvent() catch {
pardes.turn.wake();
@@ -984,7 +1000,9 @@ const Shell = struct {
defer tz_event.end();
switch (event) {
.nop => {},
- .tick => core.update(.tick),
+ .wake => if (s.test_clock) |*virtual| {
+ virtual.* = @max(virtual.*, s.wake_to);
+ },
.quit => {
core.quit = true;
return true;
@@ -1523,21 +1541,77 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
}
}
-/// Set by an animating frame's wait: post one `.tick` a frame from now. The
-/// tick joins the input queue, so a key or a click that lands inside the
-/// frame is handled at once instead of after a blind sleep.
-var tick_request: std.Io.Event = .unset;
+/// A wait with a timeout: post one `.wake` when it runs out. The wake joins
+/// the input queue, so a key or a click that lands first is handled at once
+/// instead of after a blind sleep; vaxis's queue has no timed wait. Each
+/// request bumps `wake_gen`, which cuts short a sleep still timing an
+/// earlier, longer request (an 800 ms linger interrupted by a key that
+/// starts a 16 ms fall) and times the new one instead.
+var wake_gen: std.atomic.Value(u32) = .init(0);
+var wake_ms: std.atomic.Value(u32) = .init(0);
-fn tickWatch(io: std.Io, loop: *Loop) void {
+fn requestWake(io: std.Io, ms: u32) void {
+ wake_ms.store(ms, .release);
+ _ = wake_gen.fetchAdd(1, .acq_rel);
+ io.futexWake(u32, &wake_gen.raw, 1);
+}
+
+fn wakeWatch(io: std.Io, loop: *Loop) void {
+ var seen = wake_gen.load(.acquire);
while (true) {
- tick_request.waitUncancelable(io);
- tick_request.reset();
- var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.animation.Animation.frame_ms) * std.time.ns_per_ms };
- _ = libc.nanosleep(&ts, null);
- loop.postEvent(.tick) catch {};
+ // Asleep until a request.
+ while (wake_gen.load(.acquire) == seen) io.futexWaitUncancelable(u32, &wake_gen.raw, seen);
+ seen = wake_gen.load(.acquire);
+ if (timeWake(io, &seen)) loop.postEvent(.wake) catch {};
}
}
+/// Sleep out the latest request; true when it ran its course, false when
+/// none is pending. A newer request met on the way restarts the timing.
+fn timeWake(io: std.Io, seen: *u32) bool {
+ while (true) {
+ const ms = wake_ms.load(.acquire);
+ const deadline = host_io.monotonicNs() + @as(u64, ms) * std.time.ns_per_ms;
+ while (true) {
+ const now = host_io.monotonicNs();
+ if (now >= deadline) return true;
+ io.futexWaitTimeout(u32, &wake_gen.raw, seen.*, .{ .duration = .{
+ .raw = .fromNanoseconds(@intCast(deadline - now)),
+ .clock = .awake,
+ } }) catch {};
+ const gen = wake_gen.load(.acquire);
+ if (gen != seen.*) {
+ seen.* = gen;
+ break;
+ }
+ }
+ }
+}
+
+test "a short wake request cuts short a long one still sleeping" {
+ const io = std.testing.io;
+ var seen = wake_gen.load(.acquire);
+ requestWake(io, 800);
+ seen = wake_gen.load(.acquire);
+ const Timer = struct {
+ fn run(timer_io: std.Io, start_seen: u32, done: *std.atomic.Value(u64)) void {
+ var local = start_seen;
+ _ = timeWake(timer_io, &local);
+ done.store(host_io.monotonicNs(), .release);
+ }
+ };
+ var done: std.atomic.Value(u64) = .init(0);
+ const start = host_io.monotonicNs();
+ const thread = try std.Thread.spawn(.{}, Timer.run, .{ io, seen, &done });
+ var pause: std.c.timespec = .{ .sec = 0, .nsec = 50 * std.time.ns_per_ms };
+ _ = std.c.nanosleep(&pause, null);
+ requestWake(io, 16);
+ thread.join();
+ // The 16 ms request lands about 66 ms in: well before the 800 ms one
+ // would have, however loaded the machine.
+ try std.testing.expect(done.load(.acquire) - start < 700 * std.time.ns_per_ms);
+}
+
fn wakeFs(ctx: ?*anyopaque) void {
const loop: *Loop = @ptrCast(@alignCast(ctx.?));
_ = loop.tryPostEvent(.fs_ready) catch {};
@@ -1822,7 +1896,7 @@ const Attach = struct {
fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd {
switch (event) {
- .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
+ .nop, .wake, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
.quit => return .none,
.focus_in => {},
.focus_out => return a.send(.pointer_leave),
diff --git a/src/web.zig b/src/web.zig
index b77d07a6..532e3794 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -1,7 +1,7 @@
//! Freestanding WebAssembly boundary for the DOM backend.
//!
//! JavaScript owns the clock and nothing else: it calls `pardes_tick` (one
-//! `Pardes.pump`) and reads the packed cell buffer `present` left behind. The
+//! `Pardes.pump`, at requestAnimationFrame's timestamp) and reads the packed cell buffer `present` left behind. The
//! host vtable below is only what a page genuinely has — pixels, the
//! clipboard, links, downloads. Processes, a real filesystem, a language
//! backend and subprocess pipes are left null, so the core answers them itself
@@ -106,6 +106,9 @@ const State = struct {
frame_len: usize = 0,
frame_cols: u16 = 0,
frame_rows: u16 = 0,
+ /// requestAnimationFrame's timestamp as the last tick gave it, in ns:
+ /// the core's clock (`Host.now`).
+ now_ns: u64 = 0,
fn init(cols: u16, rows: u16) !State {
const allocs = pardes.memory.init(gpa);
@@ -276,30 +279,19 @@ export fn pardes_pointer_leave() void {
s.core.update(.pointer_leave);
}
-/// One iteration of the core's own loop: queued input, effects, render,
-/// present. JS calls it once per animation frame, after spending that frame's
-/// animation ticks.
-export fn pardes_tick() void {
+/// One iteration of the core's own loop: queued input, effects, the frames
+/// of animation `timestamp_ms` (requestAnimationFrame's clock) has brought,
+/// render, present. JS calls it once per display frame; a call with no
+/// timestamp (NaN) leaves the clock where it was.
+export fn pardes_tick(timestamp_ms: f64) void {
const s = &(state orelse return);
+ if (std.math.isFinite(timestamp_ms) and timestamp_ms >= 0)
+ s.now_ns = @intFromFloat(timestamp_ms * std.time.ns_per_ms);
s.core.pump(host(s)) catch |err| {
last_error = @intFromError(err);
};
}
-/// Spend one fixed 60 Hz animation step, the only writer of animated time in
-/// this host. Separate from the pump for the reason the AppKit shell keeps them
-/// separate: if every input pump also advanced the transition, a burst of keys
-/// would collapse a ten-frame fade into one display frame.
-export fn pardes_animation_tick() void {
- const s = &(state orelse return);
- s.core.update(.tick);
-}
-
-export fn pardes_animating() u32 {
- const s = &(state orelse return 0);
- return @intFromBool(s.core.animationActive());
-}
-
/// The Exit builtin, or the last pane closing. JS stops its frame loop.
export fn pardes_should_quit() u32 {
const s = &(state orelse return 0);
@@ -462,7 +454,13 @@ extern "pardes" fn host_download(
bytes_len: usize,
) callconv(.c) void;
+fn now(ctx: ?*anyopaque) u64 {
+ const s: *State = @ptrCast(@alignCast(ctx.?));
+ return s.now_ns;
+}
+
const vtable: pardes.Host.VTable = .{
+ .now = now,
.present = present,
.write_file = writeFile,
.write_dump = writeDump,
diff --git a/src/web/app.mjs b/src/web/app.mjs
index 11d8f228..280239d5 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -1,7 +1,5 @@
const CELL_SIZE = 20;
const ATTR_TAGLINE = 1 << 7;
-const ANIMATION_FRAME_MS = 1000 / 60;
-const MAX_ANIMATION_TICKS = 4;
const DEFAULT_FG = "#cccccc";
const DEFAULT_BG = "#121212";
const decoder = new TextDecoder();
@@ -325,7 +323,6 @@ export class PardesRuntime {
this.running = false;
this.pointer = null;
this.frameHandle = 0;
- this.animationClock = { last: null, lag: 0 };
this.bindEvents();
}
@@ -338,7 +335,6 @@ export class PardesRuntime {
this.wasm.pardes_tick();
this.renderer.render();
this.running = true;
- this.animationClock = { last: null, lag: 0 };
this.frameHandle = requestAnimationFrame((timestamp) => this.frame(timestamp));
this.root.focus({ preventScroll: true });
}
@@ -355,13 +351,9 @@ export class PardesRuntime {
this.resize();
this.root.style.setProperty("--pardes-tagline-font-size", `${this.measuredTaglinePercent}%`);
}
- // JS owns the animation clock; the pump that follows applies those ticks,
- // runs the core's effects and presents one frame however fast the display
- // is. Both halves are needed: an idle core still has input and effects.
- for (let ticks = animationTicks(this.animationClock, timestamp); ticks > 0; ticks -= 1) {
- this.wasm.pardes_animation_tick();
- }
- this.wasm.pardes_tick();
+ // The core's clock is this timestamp: it steps its own animations
+ // by it, however fast the display refreshes.
+ this.wasm.pardes_tick(timestamp);
if (this.wasm.pardes_should_quit()) this.running = false;
this.renderer.render();
this.frameHandle = requestAnimationFrame((nextTimestamp) => this.frame(nextTimestamp));
@@ -559,28 +551,6 @@ export class PardesRuntime {
}
}
-/// Spend monotonic requestAnimationFrame time as fixed 60 Hz core ticks. A
-/// high-refresh display therefore renders repeated states rather than making
-/// frame-count transitions run twice as fast. Long suspension is capped so a
-/// returning tab cannot execute an unbounded loop before drawing.
-export function animationTicks(clock, timestamp) {
- if (!Number.isFinite(timestamp)) return 0;
- if (clock.last === null || timestamp < clock.last) {
- clock.last = timestamp;
- clock.lag = 0;
- return 0;
- }
- const elapsed = Math.min(timestamp - clock.last, ANIMATION_FRAME_MS * MAX_ANIMATION_TICKS);
- clock.last = timestamp;
- clock.lag += elapsed;
- const ticks = Math.min(MAX_ANIMATION_TICKS, Math.floor((clock.lag + 1e-7) / ANIMATION_FRAME_MS));
- clock.lag -= ticks * ANIMATION_FRAME_MS;
- return ticks;
-}
-
-/// The four things the core asks the page for, as the module's imports. The
-/// holder exists because the import object has to be built before the instance
-/// whose memory those imports read.
export function browserHost() {
const host = { wasm: null };
const view = (ptr, len) => new Uint8Array(host.wasm.memory.buffer, ptr, len);