diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 151 |
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()); +} |
