summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig779
1 files changed, 266 insertions, 513 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 91f0f9f4..f559b125 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1,5 +1,7 @@
const std = @import("std");
pub const layout = @import("layout.zig");
+pub const animation = @import("animation.zig");
+pub const Presentation = @import("Presentation.zig");
const uucode = @import("uucode");
const vaxis = @import("vaxis");
pub const modal = @import("modal.zig");
@@ -16,7 +18,10 @@ pub const mouse = @import("mouse.zig");
pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
+pub const draw = @import("draw.zig");
pub const Messages = @import("Messages.zig");
+pub const Registers = @import("Registers.zig");
+pub const Macro = @import("Macro.zig");
pub const Pipe = @import("Pipe.zig");
pub const colors = @import("colors.zig");
pub const Theme = colors.Theme;
@@ -35,6 +40,9 @@ pub const NativePlacement = @import("surface.zig").NativePlacement;
pub const ImagePatch = @import("surface.zig").ImagePatch;
pub const ImagePlace = @import("surface.zig").ImagePlace;
pub const Surface = @import("surface.zig").Surface;
+pub const Region = @import("surface.zig").Region;
+pub const Chrome = @import("surface.zig").Chrome;
+pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS;
pub const builtins = @import("builtins.zig");
const limits = memory.limits;
const selection_pipe = @import("selection_pipe.zig");
@@ -243,21 +251,17 @@ pub const Pane = panes.Pane;
const Text = panes.Text;
pub const MAX_PANES = 16;
-pub const BodyLayer = @import("body_layer.zig").Layer;
-pub const RowMetrics = @import("body_layer.zig").Metrics;
-pub const TagLayer = @import("tag_layer.zig").Layer;
-pub const TagHit = @import("tag_layer.zig").Hit;
-pub const TagKind = @import("tag_layer.zig").Kind;
-/// Panes, then columns, then the workspace bar, then each pane's notice bands.
+pub const Layer = @import("Layer.zig");
+pub const RowMetrics = Layer.Metrics;
+pub const TagHit = Layer.TagHit;
+pub const TagKind = Layer.Kind;
/// A tag shows at most this many lines; past them it is cut, like a line
/// too long for its width.
pub const MAX_TAG_ROWS = 8;
-pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + (MAX_PANES + MAX_COLS + 1) * (MAX_TAG_ROWS - 1);
+/// Panes, then columns, then the workspace bar, then each pane's notice
+/// bands: a band to a tag, however many lines it has.
+pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max;
pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1;
-/// Where the layers for a pane tag's second and later lines start.
-pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max;
-/// ...and those of a column's (by index) or the workspace's (after them).
-pub const HEADER_LINE_LAYER_BASE = TAG_LINE_LAYER_BASE + MAX_PANES * (MAX_TAG_ROWS - 1);
pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px;
pub const MAX_COLS = 6;
const column_weight_unit = layout.column_weight_unit;
@@ -284,7 +288,7 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 {
return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2);
}
-pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 {
+pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?animation.Track) f32 {
if (row < p.topBarHeight()) return 0;
if (row < p.topBarHeight() + p.columnBarHeight()) {
for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |x, w|
@@ -315,7 +319,7 @@ pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 {
}
test "paint order is moving, then opening, then closing tombstones on top" {
- const Track = layout.Track;
+ const Track = animation.Track;
// Deliberately interleaved on the way in: the phases are what order the
// output, not the slot they happened to occupy.
const live = [_]?Track{
@@ -329,7 +333,7 @@ test "paint order is moving, then opening, then closing tombstones on top" {
.{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
};
var out: [8]Track = undefined;
- const len = layout.paintOrder(&live, &closing, &out);
+ const len = animation.paintOrder(&live, &closing, &out);
var serials: [8]u32 = undefined;
for (out[0..len], 0..) |track, i| serials[i] = track.serial;
@@ -338,7 +342,7 @@ test "paint order is moving, then opening, then closing tombstones on top" {
// A host's array is fixed-size and the core's is not its business: writing
// past it would be a buffer overrun in whichever shell had the smaller one.
var tight: [2]Track = undefined;
- try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight));
+ try std.testing.expectEqual(@as(usize, 2), animation.paintOrder(&live, &closing, &tight));
try std.testing.expectEqual(@as(u32, 12), tight[0].serial);
try std.testing.expectEqual(@as(u32, 15), tight[1].serial);
}
@@ -453,11 +457,14 @@ test {
_ = @import("regexp.zig");
_ = @import("edit.zig");
_ = @import("Messages.zig");
+ _ = @import("Registers.zig");
+ _ = @import("Macro.zig");
_ = @import("selection_pipe.zig");
_ = @import("Pipe.zig");
_ = @import("colors.zig");
_ = @import("surface.zig");
_ = @import("body_layer.zig");
+ _ = @import("draw.zig");
_ = @import("tag_layer.zig");
}
@@ -581,18 +588,10 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
-test "scene effects keep the idle frontend animation clock active" {
+test "a post chain keeps no core clock running: the shell animates it" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
- try std.testing.expect(!p.animationActive());
-
- p.settings.scene_effects.crt = true;
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{ .ripple = true };
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{ .glitch = true };
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{};
+ try std.testing.expect(p.settings.post.set(.crt, "", true));
try std.testing.expect(!p.animationActive());
}
@@ -784,7 +783,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest
try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h);
const r = p.rects[0];
const band = for (p.surface.tagLayers()) |*layer| {
- if (layer.cols != 0 and layer.kind == .notice) break layer;
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
} else return error.NoNoticeBand;
try std.testing.expectEqual(p.bodyTop(pane, r), band.viewport.y);
try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y);
@@ -2463,7 +2462,7 @@ test "a host with no methods at all is a complete in-process pardes" {
try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y);
// the clipboard round-trips through the in-process one
- p.yank = try gpa.dupe(u8, "copied");
+ p.registers.put(gpa, '+', "copied", 0, 1, true);
p.perform(.set_clipboard);
try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items);
@@ -2526,7 +2525,7 @@ test "Font keeps requested and acknowledged faces as separate plain state" {
try std.testing.expectEqualStrings(installed[0].path, request);
try std.testing.expect(p.settings.font.pending);
try std.testing.expect(p.takeFontRequest() == null);
- const fake_track: layout.Track = .{
+ const fake_track: animation.Track = .{
.serial = p.panes[p.active].?.serial,
.pane = @intCast(p.active),
.effect = .slide,
@@ -2632,7 +2631,7 @@ test "restored sessions animate layout changes after bootstrap" {
source.dump_out.?,
);
defer restored.deinit();
- try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, restored.settings.panel_transition);
try std.testing.expect(!restored.animationActive());
var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
@@ -3256,7 +3255,7 @@ test "s in a header selects in the header's own text" {
const text = try edit.flatSurface(p, &p.global_tag);
const got = p.global_tag.ranges(text, 0, &ranges);
try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor
- try std.testing.expectEqual(@as(u8, 0), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
}
test "Enter in a header runs the word under its cursor, as the execute key does" {
@@ -3597,8 +3596,8 @@ test "layout commits publish finite tracks only for changed panes" {
try std.testing.expect(p.presentation.tracks[1] == null);
try std.testing.expect(p.presentation.tracks[0] != null);
try std.testing.expect(p.presentation.tracks[2] != null);
- try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase);
- try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase);
+ try std.testing.expectEqual(animation.Phase.moving, p.presentation.tracks[0].?.phase);
+ try std.testing.expectEqual(animation.Phase.opening, p.presentation.tracks[2].?.phase);
try std.testing.expectEqual(untouched, p.rects[1]);
try std.testing.expect(p.animationActive());
@@ -3607,7 +3606,7 @@ test "layout commits publish finite tracks only for changed panes" {
const surface = try p.render(frame.allocator());
try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len);
- for (0..layout.Transition.slide.frames()) |_| p.update(.tick);
+ for (0..animation.Transition.slide.frames()) |_| p.update(.tick);
try std.testing.expect(!p.animationActive());
_ = frame.reset(.retain_capacity);
try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len);
@@ -3646,9 +3645,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));
@@ -3669,14 +3668,14 @@ 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));
p.sync();
- for (0..layout.Transition.vertical.frames()) |_| p.update(.tick);
+ for (0..animation.Transition.vertical.frames()) |_| p.update(.tick);
_ = frame.reset(.retain_capacity);
const canonical = try p.render(frame.allocator());
try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len);
@@ -3695,9 +3694,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));
@@ -3765,7 +3764,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" {
p.acknowledgePanelPresentation(moving.panelTracks());
try std.testing.expect(moving.panelTracks().len > 0);
exec.applySettingBuiltin(p, config.Runtime.find("PanelZoom").?, null);
- try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition);
+ try std.testing.expectEqual(animation.Transition.zoom, p.settings.panel_transition);
try std.testing.expect(p.presentation.pending);
try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null);
for (p.presentation.tracks) |track| try std.testing.expect(track == null);
@@ -3819,11 +3818,11 @@ test "back-to-back layout commits retarget from the last presented boxes" {
const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget;
try std.testing.expect(target_c.from.eql(shown_a));
try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0])));
- try std.testing.expectEqual(layout.Phase.moving, target_c.phase);
+ try std.testing.expectEqual(animation.Phase.moving, target_c.phase);
_ = frame.reset(.retain_capacity);
const latest = try p.render(frame.allocator());
- var submitted: ?layout.Track = null;
+ var submitted: ?animation.Track = null;
for (latest.panelTracks()) |track| if (track.pane == 0) {
submitted = track;
break;
@@ -3846,13 +3845,13 @@ test "an unpresented opening pane remains in the opening paint phase" {
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
p.sync();
const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack;
- try std.testing.expectEqual(layout.Phase.opening, first.phase);
+ try std.testing.expectEqual(animation.Phase.opening, first.phase);
_ = try p.newShell(2, "");
try std.testing.expect(layout.splitColumn(p, 1, 2, true));
p.sync();
const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget;
- try std.testing.expectEqual(layout.Phase.opening, retargeted.phase);
+ try std.testing.expectEqual(animation.Phase.opening, retargeted.phase);
try std.testing.expect(retargeted.from.eql(first.from));
}
@@ -3890,7 +3889,7 @@ test "core composes ASCII bytes while visual-only cells pass through" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- const presented = try p.composeAsciiTransitions(arena.allocator(), &surface);
+ const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface);
try std.testing.expect(presented != &surface);
try std.testing.expectEqualStrings("C", presented.at(0, 0).grapheme());
try std.testing.expectEqualStrings("x", presented.at(1, 0).grapheme());
@@ -3925,7 +3924,7 @@ test "core composes character motion out of the new grid, not a fade" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- const presented = try p.composeAsciiTransitions(arena.allocator(), &surface);
+ const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface);
try std.testing.expect(presented != &surface);
var seen: [8]u8 = undefined;
for (&seen, 0..) |*byte, col| byte.* = presented.at(@intCast(col), 0).text[0];
@@ -3933,8 +3932,8 @@ test "core composes character motion out of the new grid, not a fade" {
try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme());
// The last active sample is the untouched canonical grid, with no copy.
- surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1;
- try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface);
+ surface.panel_tracks[0].frame = animation.Transition.edges.frames() - 1;
+ try std.testing.expect(try draw.composeAsciiTransitions(p, arena.allocator(), &surface) == &surface);
}
test "repeated non-dyadic column splits preserve every unrelated boundary" {
@@ -4088,7 +4087,7 @@ pub const Pardes = struct {
rects: [MAX_PANES]Rect = @splat(.{}),
col_x: [MAX_COLS]u16 = undefined,
col_w: [MAX_COLS]u16 = undefined,
- presentation: layout.Presentation = .{},
+ presentation: Presentation = .{},
active: usize = 0,
jumps: [MAX_JUMPS]Loc = undefined,
@@ -4140,6 +4139,14 @@ pub const Pardes = struct {
multi_on: bool = false,
multi_first: bool = false,
multi_stop: bool = false,
+ /// While a key is replayed across the ranges: which range this pass is,
+ /// in document order, and how many there are. A register value belongs
+ /// to its range by this index.
+ multi_index: usize = 0,
+ multi_count: usize = 1,
+ /// The register the running normal command names (`"<reg>`); 0 for the
+ /// default one.
+ register: u21 = 0,
leader_on: bool = false,
leader_keys: [4]u8 = undefined,
leader_n: u8 = 0,
@@ -4192,6 +4199,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.
@@ -4220,7 +4234,10 @@ pub const Pardes = struct {
in_head: usize = 0,
in_len: usize = 0,
- yank: ?[]u8 = null,
+ registers: Registers = .{},
+ macro: Macro = .{},
+ /// an edited text nothing kept (a tag's shown text): see edit.retireEditText
+ retired_edit: ?[]u8 = null,
clip_pending: ?edit.ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
@@ -4266,6 +4283,8 @@ pub const Pardes = struct {
.pdf_gpa = pdf_gpa,
.tree_sitter_gpa = tree_sitter_gpa,
.opts = opts,
+ .col_tags = @splat(.{ .what = .column, .gpa = gpa }),
+ .global_tag = .{ .what = .workspace, .gpa = gpa },
.fs = .{},
.screen_w = opts.cols,
.screen_h = opts.rows,
@@ -4391,7 +4410,9 @@ pub const Pardes = struct {
const gpa = p.gpa;
for (&p.col_tags) |*t| t.deinit(gpa);
p.global_tag.deinit(gpa);
- if (p.yank) |y| gpa.free(y);
+ p.registers.deinit(gpa);
+ p.macro.deinit(gpa);
+ if (p.retired_edit) |r| gpa.free(r);
if (p.dump_out) |d| gpa.free(d);
p.fallback.deinit();
if (p.last_dump) |d| gpa.free(d);
@@ -4513,6 +4534,7 @@ pub const Pardes = struct {
pane.body.deinit(p.gpa);
pane.tag.deinit(p.gpa);
pane.input.deinit(p.gpa);
+ pane.jump.deinit(p.gpa);
panes.Terminal.deinitEmulator(pane, p.gpa);
pane.clearCwd();
pane.fs.deinit(p.gpa);
@@ -4970,7 +4992,7 @@ pub const Pardes = struct {
}
},
.set_clipboard => {
- const text = p.yank orelse "";
+ const text = p.registers.text(p.gpa, '+') orelse "";
if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text);
},
// No desktop to ask: answer from the in-process clipboard at once,
@@ -5011,7 +5033,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()) layout.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();
@@ -5021,7 +5053,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);
@@ -5261,7 +5295,21 @@ pub const Pardes = struct {
} else panes.Terminal.enterTty(p, id);
}
+ /// Type `keys` again (a macro, `.`): through the modal handling as
+ /// typed, except that no key leaves the text for pardes's own acts --
+ /// Esc does not hop panes, and Enter and Tab neither look nor execute.
+ pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void {
+ if (p.macro.replaying >= 8) return; // a macro typing itself
+ p.macro.replaying += 1;
+ defer p.macro.replaying -= 1;
+ for (keys) |stored| {
+ var buf: [4]u8 = undefined;
+ p.handleKey(stored.typed(&buf));
+ }
+ }
+
fn handleKey(p: *Pardes, key: Key) void {
+ if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key);
// A question waiting on a pane takes the next key, whatever mode the
// pane or a header edit is in: that key is its answer.
for (p.panes, 0..) |slot, id| {
@@ -5316,7 +5364,7 @@ pub const Pardes = struct {
// in it the chord is the program's -- vim's visual block,
// readline's quoted-insert -- and swallowing it would make
// Ctrl-V a black hole in every full-screen application.
- if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text);
+ if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text);
}
return panes.Terminal.forwardKey(p, p.active, key);
}
@@ -5393,7 +5441,7 @@ pub const Pardes = struct {
.search => if (submit)
look.submitSearch(p, p.active)
else if (normal.selRegexArmed(pane)) |_| if (target) |answered|
- normal.applySelRegex(p, pane, answered, "", false),
+ normal.applySelRegex(p, pane, answered, "", .select),
.none, .del_side, .repl_choice, .name => unreachable,
}
pane.closePrompt();
@@ -5404,10 +5452,11 @@ pub const Pardes = struct {
return;
}
edit.handleInsert(p, &pane.input, key);
- if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode);
return;
}
- if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
+ if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key);
+ if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) {
// A header's words are the workspace's and column's commands, so
// Enter runs them there, as the execute key does.
const header_word = t.what == .column or t.what == .workspace;
@@ -5454,19 +5503,45 @@ pub const Pardes = struct {
if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return;
// A tag's prefix is read-only: a key whose edit reached into it
// changes nothing, not even where the cursor is.
- const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel };
+ const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel };
+ var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined;
+ const kept_n = t.nsel;
+ @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]);
t.refused = false;
defer if (t.refused) {
- t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept;
+ t.cur_row, t.cur_col, t.vsel, t.msel = kept;
+ t.restoreSels(kept_sels[0..kept_n]);
};
+ const was = t.mode;
switch (t.mode) {
.normal => normal.handleNormal(p, t, key),
- .insert => {
- if (hit(key, config.escape)) return edit.exitInsert(p, t);
- edit.handleInsert(p, t, key);
- },
+ .insert => if (hit(key, config.escape) and !t.reg_wait)
+ edit.exitInsert(p, t)
+ else
+ edit.handleInsert(p, t, key),
.tty => panes.Terminal.forwardKey(p, p.active, key),
}
+ // `.`'s log: the keys of the normal command that entered insert
+ // mode, then everything typed until it is left
+ if (p.macro.repeating) return;
+ const m = &p.macro;
+ switch (was) {
+ .normal => {
+ Macro.append(&m.command, p.gpa, key);
+ if (t.mode == .insert) {
+ m.last_insert.clearRetainingCapacity();
+ m.last_insert.appendSlice(p.gpa, m.command.items) catch {};
+ m.typed_from = m.last_insert.items.len;
+ m.inserting = true;
+ m.command.clearRetainingCapacity();
+ } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity();
+ },
+ .insert => if (m.inserting) {
+ Macro.append(&m.last_insert, p.gpa, key);
+ if (t.mode != .insert) m.inserting = false;
+ },
+ .tty => {},
+ }
}
fn leaderKey(p: *Pardes, key: Key) void {
@@ -5801,7 +5876,7 @@ pub const Pardes = struct {
// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
- pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void {
+ pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const animation.Track) void {
p.presentation.acknowledge(p, tracks);
if (p.pointer_inside and p.drag != .none) {
if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| {
@@ -5818,10 +5893,10 @@ pub const Pardes = struct {
look.cancelLookHover(p);
}
- /// A native scene pass proved unavailable. Config reads these exact bits,
- /// and the idle-clock predicate stops immediately once they are clear.
+ /// A native post pass proved unavailable: the chain is emptied, which
+ /// Config then reports.
pub fn disableSceneEffects(p: *Pardes) void {
- p.settings.scene_effects = .{};
+ p.settings.post.clear();
}
// ---- doc panes ----
@@ -6129,18 +6204,83 @@ 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;
+ 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;
@@ -6157,15 +6297,14 @@ pub const Pardes = struct {
pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit {
for (p.surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue;
+ if (layer.rows == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue;
if (layer.kind == .pane) {
if (layer.id >= p.panes.len) return null;
const pane = p.panes[layer.id] orelse return null;
if (pane.serial != layer.serial) return null;
}
if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null;
- // A taller tag has a layer per line; the point is on one of them.
- return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue;
+ return layer.tagHit(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue;
}
return null;
}
@@ -6183,446 +6322,21 @@ pub const Pardes = struct {
}
pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 {
- const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } };
+ const layer: Layer = .{ .kind = .pane, .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } };
return layer.capacity(p.row_metrics);
}
pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit {
for (&p.surface.body_layers) |*layer| {
if (layer.rows == 0 or layer.serial != body_hit.serial) continue;
- return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h));
+ return layer.bodyHit(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h));
}
return null;
}
- // ---- render: build the canonical surface ----
-
- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
- panes.File.refreshHighlights(p);
- const s = &p.surface;
- const ncells = @as(usize, p.screen_w) * p.screen_h;
- if (s.cells.len != ncells) {
- const cells = try p.gpa.alloc(Cell, ncells);
- p.gpa.free(s.cells);
- s.cells = cells;
- }
- s.cols = p.screen_w;
- s.rows = p.screen_h;
- s.cursor = null;
- s.pointer_shape = .arrow;
- if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| {
- if (preview.probe.link != null and preview.pane < p.panes.len) {
- if (p.panes[preview.pane]) |pane| {
- if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane))
- s.pointer_shape = .link;
- }
- }
- };
- // A look-hover preview means the pointed word is a real Look or Exec
- // operand: the same plumbing a click would act on. Hosts that model
- // the pointer per-text (the macOS I-beam) read this as "target" and
- // afford it the arrow instead.
- if (p.look_hover_preview != null) s.pointer_shape = .target;
- // Stale hover bits must not survive into a frame whose pointer has
- // moved on: the ordinary content paint does not clear fields it
- // never set.
- for (s.cells) |*c| c.hover = false;
- for (&s.body_layers) |*layer| layer.rows = 0;
- for (&s.tag_layers) |*layer| layer.cols = 0;
- s.nimages = 0;
- s.npanel_tracks = 0;
- s.previous_cells = &.{};
- s.previous_body_layers = &.{};
- s.previous_tag_layers = &.{};
- s.cell_diffs = &.{};
- const chrome = p.chromeTheme();
- const th = p.theme();
- // Separators have their own role; a scrollbar need not frame the page.
- s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } });
-
- for (&p.panes, 0..) |*slot, id| {
- const pane = slot.* orelse continue;
- pane.body_rows = 0;
- pane.body_visible_rows = 0;
- pane.context_row_limit = null;
- s.body_layers[id].rows = 0;
- Messages.collectNotices(p, pane, p.rects[id], id);
- try p.renderPane(arena, pane, p.rects[id], id, id == p.active);
- try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id);
- }
- for (&p.panes, 0..) |*slot, id| {
- const pane = slot.* orelse continue;
- if (pane.notices.len == 0) continue;
- const r = p.rects[id];
- const tx = r.x + config.GUTTER;
- const tw = r.w - config.GUTTER;
- // Painted the way the sticky context headers above them are: the
- // tagline font and the tagline's own colours, so a notice reads as
- // a band belonging to the pane rather than as a line of body text
- // that happens to sit at the bottom of it.
- const msg_style: CellStyle = .{
- .fg = .{ .rgb = chrome.tag_fg },
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- };
- // An OVERLAY over the body's top rows, at the right edge, one
- // chip to a row in the order collectNotices chose -- not a row
- // taken out of the body the way a tree-sitter context header is.
- // The chip is as wide as its own message and no wider, so the text
- // beneath the rest of the row keeps reading as the file it is.
- const first = p.bodyTop(pane, r);
- var leader_buf: [16]u8 = undefined;
- for (pane.notices.slice(), 0..) |kind, i| {
- const row = first + @as(u16, pane.notices.row[i]);
- // Every notice is painted on the canonical grid as well as
- // emitted as a tag layer: the grid is what a terminal client
- // draws, and a band it cannot see is a message it never gets.
- // The GUI skips grid cells a tag layer covers, so nothing is
- // drawn twice there.
- const text = Messages.noticeText(p, pane, i, &leader_buf);
- const chip = Messages.noticeCols(p, text, tw);
- if (chip == 0) continue;
- const cx = tx + tw - chip;
- pane.notices.left[i] = cx;
- // A message coming or going blends toward the page it sits
- // on; a grid cannot move it by less than a row.
- var style = msg_style;
- // Where an exec was sent to a REPL reads in the tag's name
- // tint, set apart from what the editor says.
- const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg;
- if (kind == .message and pane.msg_sent) style.fg = .{ .rgb = ink };
- if (Messages.noticeLife(pane, i)) |life| {
- // No page colour to fade to (a terminal's own default):
- // the text dissolves into its chip instead.
- const alpha = Messages.messageMotion(p, life).alpha;
- const page = th.bg orelse chrome.tag_bg;
- style.fg = .{ .rgb = Messages.blendRgb(ink, page, alpha) };
- style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) };
- }
- s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline });
- // One column short of the chip's right edge: a prompt's caret
- // sits AFTER its last character the moment it opens, and flush
- // right would put it past the pane, off the grid, and past what
- // the detached wire will encode -- which drops every frame for
- // as long as the prompt is up.
- const printed = Messages.printRight(s, cx, row, chip -| 1, text, style);
- if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) continue;
- // The cursor follows the text to wherever it landed; a caret
- // in the part a narrow band dropped has nowhere to be.
- const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
- if (prompt_col < printed.dropped) continue;
- const caret = printed.left + (prompt_col - printed.dropped);
- if (caret < tx + tw)
- s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert };
- }
- }
+ // ---- render: build the canonical surface (draw.zig) ----
- try body_layer.paintHeaders(p);
- try body_layer.renderTagLayers(p, arena);
-
- switch (p.drag) {
- .border_v => |d| {
- if (d.cur_x < s.cols) {
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
- }
- if (d.corner) |k| if (d.cur_y < s.rows) {
- var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1;
- const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1;
- while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
- };
- },
- .border_h => |d| if (d.cur_y < s.rows) {
- var col = p.col_x[d.col];
- while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
- },
- .column_move => |d| rail: {
- const source = layout.columnBySerial(p, d.serial) orelse break :rail;
- // Where the column's left edge would land, reordered or not.
- const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
- .stay => break :rail,
- .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
- .edge => |x| x,
- };
- // Leave the outside insertion rail visible above compact
- // text layers. This clips presentation only, never reflows.
- if (edge == s.cols) {
- for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) {
- layer.viewport.w -|= 1;
- };
- for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) {
- layer.viewport.w -|= 1;
- };
- }
- // The rail runs in the seam cell left of the edge, where a
- // border drag's does: the edge cell itself is a gutter, which
- // the GUI paints as a scroll rail that swallowed the dashes.
- // At the window's left edge there is no seam, so it runs just
- // past that scroll rail instead. It takes the lit grip's
- // color: this is where the grabbed column lands.
- const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1);
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) {
- // Past the left edge's scroll rail the rail would cut
- // through column 0's grip and pane boxes: leave them whole.
- if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue;
- s.overlayDash(x, row, "╎");
- s.at(x, row).style.fg = .{ .rgb = chrome.column_box };
- }
- },
- .move => |d| if (d.cur_x < s.cols) {
- if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| {
- var col: u16 = p.col_x[placement.preview_col];
- while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
- s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } });
- }
- s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
- }
- },
- .none, .select => if (p.pointer_inside) {
- // hover hints on a pane's own trailing edge
- var c: usize = 0;
- while (c + 1 < p.ncol) : (c += 1) {
- if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
- }
- }
- for (0..p.ncol) |cc| {
- if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
- // the same seam the h-handle hit test picks (see there)
- if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
- var col = p.col_x[cc];
- while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
- }
- }
- for (0..p.ncol -| 1) |cn| {
- if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
- if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
- if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue;
- var col = p.col_x[cn + 1];
- while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
- }
- },
- }
-
- // debug overlay: a small stats box drawn last, top-right ("Debug")
- if (p.settings.debug) if (p.panes[p.active]) |at| {
- var nlive: usize = 0;
- for (p.panes) |slot| {
- if (slot != null) nlive += 1;
- }
- var sb_off: usize = undefined;
- var sb_total: usize = undefined;
- if (at.file) |*f| {
- sb_off = f.scroll;
- sb_total = panes.File.nlines(p.gpa, f);
- } else if (at.pdfPage()) |page| {
- sb_off = page;
- sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
- } else {
- const sb = panes.Terminal.scrollbar(at);
- sb_off = sb.offset;
- sb_total = sb.total;
- }
- const effective_font = p.settings.font.effective_name.get();
- const font_name = if (effective_font.len == 0) "default" else effective_font;
- var ov_buf: [256]u8 = undefined;
- const text = std.fmt.bufPrint(
- &ov_buf,
- "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
- .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
- ) catch "";
- // wide enough for the longest theme name compiled in, which is the
- // only row here whose width is not ours to choose
- const ow: u16 = 38;
- const oh: u16 = 10;
- const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
- if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) {
- // vaxis single_rounded border ring, default style
- s.set(ox, p.topBarHeight(), "╭", .{});
- s.set(ox + ow - 1, p.topBarHeight(), "╮", .{});
- s.set(ox, p.topBarHeight() + oh - 1, "╰", .{});
- s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{});
- var bx = ox + 1;
- while (bx < ox + ow - 1) : (bx += 1) {
- s.set(bx, p.topBarHeight(), "─", .{});
- s.set(bx, p.topBarHeight() + oh - 1, "─", .{});
- }
- var by = p.topBarHeight() + 1;
- while (by < p.topBarHeight() + oh - 1) : (by += 1) {
- s.set(ox, by, "│", .{});
- s.set(ox + ow - 1, by, "│", .{});
- }
- s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } });
- var lines = std.mem.splitScalar(u8, text, '\n');
- var ly: u16 = p.topBarHeight() + 1;
- while (lines.next()) |line| : (ly += 1) {
- if (ly >= p.topBarHeight() + oh - 1) break;
- _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } });
- }
- }
- };
- try p.presentation.submit(p, s);
- return p.composeAsciiTransitions(arena, s);
- }
-
- fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface {
- _ = p;
- if (!canonical.hasPanelDiff()) return canonical;
- var presented: ?*Surface = null;
- for (canonical.panelTracks()) |track| {
- if (!track.effect.composedByCore() or track.phase == .closing) continue;
- const area = layout.CellArea.of(track.to);
- const col_end = @min(canonical.cols, area.x0 +| area.cols);
- const row_end = @min(canonical.rows, area.y0 +| area.rows);
- var row: u16 = area.y0;
- while (row < row_end) : (row += 1) {
- var col: u16 = area.x0;
- while (col < col_end) : (col += 1) {
- const index = @as(usize, row) * canonical.cols + col;
- const composed = composedCell(canonical, track, area, col, row, index) orelse
- continue;
- if (composed.visuallyEqual(&canonical.cells[index])) continue;
- if (presented == null) {
- const next = try arena.create(Surface);
- next.* = canonical.*;
- next.cells = try arena.dupe(Cell, canonical.cells);
- presented = next;
- }
- presented.?.cells[index] = composed;
- }
- }
- }
- return presented orelse canonical;
- }
-
- fn composedCell(
- canonical: *const Surface,
- track: layout.Track,
- area: layout.CellArea,
- col: u16,
- row: u16,
- index: usize,
- ) ?Cell {
- if (track.effect == .ascii) {
- const diff = switch (canonical.cell_diffs[index]) {
- .ascii => |diff| diff,
- .unchanged, .visual => return null,
- };
- const byte = diff.byteAt(track.frame);
- if (byte == diff.to) return null;
- if (track.frame == 0) return canonical.previous_cells[index];
- return withByte(canonical.cells[index], byte);
- }
- return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) {
- .old => canonical.previous_cells[index],
- .byte => |byte| withByte(canonical.cells[index], byte),
- // Churn belongs on the glyph, not on the pane's empty space, and it
- // borrows whichever side of the diff actually has one.
- .churn => |byte| blk: {
- const new_cell = &canonical.cells[index];
- if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte);
- const old_cell = &canonical.previous_cells[index];
- if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte);
- break :blk old_cell.*;
- },
- .at => |offset| blk: {
- const source_col = @as(i32, col) + offset.cols;
- const source_row = @as(i32, row) + offset.rows;
- // A glyph whose source lies outside the pane has not arrived
- // yet: the frozen old cell is the only truthful thing under it.
- if (source_col < area.x0 or source_row < area.y0 or
- source_col >= @as(i32, area.x0) + area.cols or
- source_row >= @as(i32, area.y0) + area.rows or
- source_col >= canonical.cols or source_row >= canonical.rows)
- break :blk canonical.previous_cells[index];
- const source = @as(usize, @intCast(source_row)) * canonical.cols +
- @as(usize, @intCast(source_col));
- break :blk canonical.cells[source];
- },
- };
- }
-
- fn withByte(cell: Cell, byte: u8) Cell {
- var out = cell;
- out.text[0] = byte;
- out.len = 1;
- out.default = false;
- return out;
- }
-
- /// Whether a cell shows a glyph a character effect can work on. An unpainted
- /// cell and a space are both blank space, whatever style they carry.
- fn hasGlyph(cell: *const Cell) bool {
- return !cell.default and !(cell.len == 1 and cell.text[0] == ' ');
- }
-
- fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
- const tz = tracy.zone(@src(), "renderPane");
- defer tz.end();
- if (r.w <= config.GUTTER or r.h == 0) return;
- const s = &p.surface;
- const th = p.theme();
- const chrome = p.chromeTheme();
- const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
- const tw = r.w - config.GUTTER;
- const tag_y = p.tagTop(pane, r);
- const body_y = p.bodyTop(pane, r);
- const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
-
- // text area resets to terminal-default cells (vaxis clear semantics);
- // light themes paint the page over it.
- {
- // Two full passes over every cell in the pane, every frame.
- const tz_clear = tracy.zone(@src(), "paneClear");
- defer tz_clear.end();
- s.clearRect(tx, r.y, tw, r.h);
- if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
- }
-
- const box_bg = if (active) chrome.box else chrome.box_dim;
- const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
- const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
- const box_style: CellStyle = .{
- .fg = .{ .rgb = box_ink },
- .bg = .{ .rgb = box_bg },
- // Grips keep physical grid dimensions, independently of tag text.
- .font_role = .tagline,
- };
- s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
- s.set(r.x, tag_y, switch (pane.body.mode) {
- .normal => config.tag_normal,
- .insert => config.tag_insert,
- .tty => config.tag_tty,
- }, box_style);
-
- try body_layer.paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET));
-
- // Folded panes retain their body state, but neither body pixels nor
- // its cursor belong outside the one-row tag rectangle.
- if (pane.collapsed) return;
-
- if (comptime pdf_enabled)
- if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return;
-
- if (pane.image) |*iv| {
- // Below the notice chips: a picture is drawn after the cells (the
- // GUI's image pass, kitty's z-order), so a chip over it would be
- // painted out. Text gets the overlay; a picture gives up the rows.
- const shown = pane.notices.span();
- const image_h = (r.h -| pane.tag_rows) -| shown;
- panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h);
- // thumbless, but the same one column as the real scrollbar below —
- // that is the whole point of drawing it, and like that one it runs
- // past the notice bands so the gutter has no notch in it
- s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg });
- return;
- }
-
- try body_layer.renderBody(p, arena, pane, r, id, active, true);
- }
+ pub const render = draw.render;
pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool {
const s = &p.surface;
@@ -6861,12 +6575,12 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch
// A pixel shell draws the prompt from its band, in the tagline's pitch,
// with the caret right after the marker's last glyph.
const band = for (p.surface.tagLayers()) |*layer| {
- if (layer.cols != 0 and layer.kind == .notice) break layer;
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
} else return error.NoPromptBand;
try std.testing.expectEqual(row, band.viewport.y);
const caret = band.cursor orelse return error.MissingBandCaret;
try std.testing.expect(caret.bar);
- try std.testing.expectEqualStrings("/", band.cells[caret.col - 1].grapheme());
+ try std.testing.expectEqualStrings("/", band.cells[caret.x - 1].grapheme());
// The chip covers only its own columns. Left of it is ordinary body, and
// a sweep there still selects.
@@ -6912,11 +6626,11 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro
try std.testing.expect(p.surface.tag_layers[0].cursor == null);
// The band carries the answer and the caret, one cell back from its end.
const band = for (p.surface.tagLayers()) |*layer| {
- if (layer.cols != 0 and layer.kind == .notice) break layer;
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
} else return error.NoPromptBand;
const caret = band.cursor orelse return error.MissingBandCaret;
- try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme());
- try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme());
+ try std.testing.expectEqualStrings("t", band.cells[caret.x - 1].grapheme());
+ try std.testing.expectEqualStrings("w", band.cells[caret.x].grapheme());
const grid = p.surface.cursor orelse return error.MissingPromptCursor;
try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y);
@@ -7642,9 +7356,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();
@@ -7719,7 +7433,7 @@ test "jump history clears selections and lands at the recorded cursor" {
try std.testing.expectEqual(@as(usize, 2), p.njumps);
try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line);
try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and !pane.body.select);
- try std.testing.expectEqual(@as(u8, 0), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
try std.testing.expectEqual(.none, pane.sel[0].state);
try std.testing.expect(pane.pointer_selections[0] == null);
try std.testing.expectEqual(@as(i32, if (index == 0) 2 else 0), pane.body.cur_row);
@@ -7755,3 +7469,42 @@ 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 the test wants: the theme
+ // fade, held at its start before every step.
+ 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.chrome_animation.step = 0;
+ 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.chrome_animation.step = 0;
+ p.advance(before + 600 * std.time.ns_per_s);
+ try std.testing.expectEqual(before + 600 * std.time.ns_per_s, p.stepped_ns);
+ p.chrome_animation.step = animation.transition_steps;
+ p.advance(p.stepped_ns + frame);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+}