summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig151
1 files changed, 137 insertions, 14 deletions
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());
+}