diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1013 |
1 files changed, 465 insertions, 548 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 187f3ed3..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,6 +18,7 @@ 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"); @@ -37,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"); @@ -245,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; @@ -286,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| @@ -317,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{ @@ -331,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; @@ -340,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); } @@ -462,6 +464,7 @@ test { _ = @import("colors.zig"); _ = @import("surface.zig"); _ = @import("body_layer.zig"); + _ = @import("draw.zig"); _ = @import("tag_layer.zig"); } @@ -556,7 +559,7 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le p.perform(.{ .detach = asked }); const pane = p.panes[0].?; - try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]); + try std.testing.expectEqualStrings("detach: not attached", pane.msg[0..pane.msg_len]); try std.testing.expect(!p.quit); } @@ -585,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()); } @@ -788,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); @@ -1197,7 +1192,7 @@ test "first document fallback splits like Tty from the originating pane" { const free = p.freeSlot().?; const doc = try p.openTutorView(free); exec.placeDoc(p, from, free, doc); - exec.spawnTty(tty, from); + _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); try std.testing.expectEqual(@as(usize, 2), p.ncol); @@ -1365,6 +1360,33 @@ test "Exec in a terminal whose tty is taken runs as a command pane instead of ty try std.testing.expect(spawned); } +test "a command of several lines keeps its tag one row, and its echo does not staircase" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const doc = try p.newDocPane(1); + doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pic.ppm") }; + const two = "def greet(name):\n return name\n\nprint(greet(\"x\"))\n"; + const dst = exec.execute(p, 1, two) orelse return error.NoCommandPane; + while (p.nextEffect()) |_| {} + const pane = p.panes[dst].?; + const prefix = try tagline.tagPrefix(p, pane); + try std.testing.expect(std.mem.indexOfScalar(u8, prefix, '\n') == null); + try std.testing.expect(std.mem.indexOf(u8, prefix, "(def greet(name):...) running") != null); + // Rendered without a panic, however the tag wraps. + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 0 } }); + _ = exec.execute(p, 1, two); + while (p.nextEffect()) |_| {} + _ = try p.render(frame.allocator()); + const body = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(body); + try std.testing.expect(std.mem.indexOf(u8, body, "% def greet(name):\n return name") != null); +} + test "a spawn is for the pane it was made for, not whatever took the slot since" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -1423,6 +1445,11 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe return to[pane][0..len[pane]]; } }; + // The programs the REPLs run hold their terminals' ttys. + var host: FakeTtyQuery = .{}; + host.install(p); + host.taken[1] = true; + host.taken[2] = true; // Only a terminal is a REPL; a language's name is the syntax table's. try std.testing.expect(p.executeBuiltinLine(0, "Repl python")); try std.testing.expect(file.repl == null); @@ -1430,23 +1457,50 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe try std.testing.expect(p.panes[1].?.repl == null); try std.testing.expect(std.mem.indexOf(u8, p.panes[1].?.msg[0..p.panes[1].?.msg_len], "python") != null); try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); - try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); // again: no change + // again, as a code fence may name it: no change + try std.testing.expect(p.executeBuiltinLine(1, "Repl PY")); var idbuf: [48]u8 = undefined; try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, p.panes[1].?), " python-a")); Sent.drain(p); // A click on the file's body, a single word: typed into the REPL. - p.exec_from_body = true; - try std.testing.expectEqual(@as(?usize, 1), exec.execute(p, 0, "ls")); + try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 0, "ls", true)); Sent.drain(p); try std.testing.expectEqualStrings("ls\r", Sent.said(1)); try std.testing.expect(!Sent.spawned); try std.testing.expect(file.msg_sent); try std.testing.expectEqualStrings("\u{2192} python-a", file.msg[0..file.msg_len]); + // A body's click written back to event is done as the click: sent. A + // line written to exec came back as F and runs as the command it was. + { + const th = @import("ninep/testing.zig"); + const event = ctlfs.Node.of(file.serial, .event); + const h = th.call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + defer _ = th.call(p, .{ .tag = 2, .op = .release, .node = event, .handle = h }); + // The 9P harness performs the effects: what it wrote is its answer's. + const sent = th.wr(p, event, "MX0 8\n"); + try std.testing.expectEqualStrings("print(1)\r", sent.pty_buf[0..sent.pty_len]); + const ran = th.wr(p, event, "FX0 0 2 2 ls\n"); + try std.testing.expect(ran.pty_len == 0 and ran.spawned); + // The pane's ctl line ends with the id it is bound as. + var line: [ctlfs.ctl.pane_text_cap]u8 = undefined; + try std.testing.expect(std.mem.endsWith(u8, ctlfs.ctl.paneText(p, p.panes[1].?, &line), " python-a ")); + } + // What is sent is plain text: CRLF is LF, no ESC or other control. + _ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true); + Sent.drain(p); + try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1)); + // Several selections executed at once: each is sent, not the first. + { + const text = try edit.flatSurface(p, &file.body); + file.body.setRanges(text, &.{ .{ .anchor = 0, .head = 8 }, .{ .anchor = 9, .head = 11 } }, &.{}, 0, true); + try std.testing.expect(exec.chordEachSel(p, file, .Exec)); + Sent.drain(p); + try std.testing.expectEqualStrings("print(1)\rls\r", Sent.said(1)); + } // Its own body too. - p.exec_from_body = true; - try std.testing.expectEqual(@as(?usize, 1), exec.execute(p, 1, "x = 1")); + try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 1, "x = 1", true)); Sent.drain(p); try std.testing.expectEqualStrings("x = 1\r", Sent.said(1)); @@ -1457,8 +1511,7 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe try std.testing.expect(p.executeBuiltinLine(0, "Exec ls")); Sent.drain(p); try std.testing.expect(Sent.spawned and Sent.len[1] == 0); - p.exec_from_body = true; - _ = exec.execute(p, 0, "@`ls`"); + _ = exec.executeFrom(p, 0, "@`ls`", true); Sent.drain(p); try std.testing.expect(Sent.spawned and Sent.len[1] == 0); @@ -1466,19 +1519,38 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); Sent.drain(p); - p.exec_from_body = true; - try std.testing.expectEqual(@as(?usize, null), exec.execute(p, 0, "print(2)")); + try std.testing.expectEqual(@as(?usize, null), exec.executeFrom(p, 0, "print(2)", true)); try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt); try std.testing.expect(std.mem.indexOf(u8, file.promptText().?, "a b") != null); p.update(.{ .key = .{ .cp = 'b' } }); Sent.drain(p); try std.testing.expectEqualStrings("print(2)\r", Sent.said(2)); try std.testing.expect(Sent.len[1] == 0 and file.prompt == .none); - p.exec_from_body = true; - _ = exec.execute(p, 0, "print(3)"); + _ = exec.executeFrom(p, 0, "print(3)", true); p.update(.{ .key = .{ .cp = Key.escape } }); Sent.drain(p); try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0); + // The pane a letter offered is the one it answers for: unbound while + // the question stood, it is sent nothing, and that is said. + _ = exec.executeFrom(p, 0, "print(4)", true); + try std.testing.expect(p.executeBuiltinLine(2, "Repl -")); + p.update(.{ .key = .{ .cp = 'b' } }); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0); + try std.testing.expectEqualStrings("Repl: the REPL chosen is gone; nothing sent", file.msg[0..file.msg_len]); + try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); + try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); + + // A REPL whose program has left (Ctrl-D: the shell has the tty) is + // sent nothing, and nothing runs: it says so. + host.taken[1] = false; + host.taken[2] = false; + _ = exec.executeFrom(p, 0, "import os", true); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0 and !Sent.spawned); + try std.testing.expect(std.mem.indexOf(u8, file.msg[0..file.msg_len], "no program running") != null); + host.taken[1] = true; + host.taken[2] = true; // Unbound, its letter is free again; bare, it says what it is. try std.testing.expect(p.executeBuiltinLine(1, "Repl -")); @@ -1487,17 +1559,62 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); try std.testing.expect(p.executeBuiltinLine(2, "Repl")); try std.testing.expectEqualStrings("Repl: python-b", p.panes[2].?.msg[0..p.panes[2].?.msg_len]); + + // A command pane is a REPL while its command runs: done, it is none, + // and a done one cannot be bound. A document pane is no terminal. + const cmd = p.freeSlot().?; + _ = try p.newCommand(cmd, "", "python3"); + try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python")); + try std.testing.expect(p.panes[cmd].?.repl != null); + p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 0 } }); + try std.testing.expect(p.panes[cmd].?.repl == null); + try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python")); + try std.testing.expect(p.panes[cmd].?.repl == null); + const doc_slot = p.freeSlot().?; + const doc = try p.newDocPane(doc_slot); + try std.testing.expect(p.executeBuiltinLine(doc_slot, "Repl python")); + try std.testing.expect(doc.repl == null); +} + +test "a restored terminal's tag names neither the REPL it was nor the shell it had" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.acknowledgeShell(0, "/usr/bin/fish", true); + var host: FakeTtyQuery = .{}; + host.install(p); + try std.testing.expect(p.executeBuiltinLine(0, "Repl python")); + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "test"); + defer restored.deinit(); + const pane = restored.panes[0].?; + try std.testing.expect(pane.repl == null and pane.tag.own == null); + const shown = try tagline.tagText(restored, restored.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, shown, "python-a") == null); + try std.testing.expect(std.mem.indexOf(u8, shown, "Tty+fish") == null); } test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - try std.testing.expect(p.executeBuiltinLine(0, "Tty+fish")); + // sh, the one shell every host has. + try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh")); try std.testing.expect(p.active != 0); - try std.testing.expectEqualStrings("fish", p.panes[p.active].?.shell.?); - p.acknowledgeShell(p.active, "/usr/bin/fish", true); - try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+fish ")); + try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?); + p.acknowledgeShell(p.active, "/bin/sh", false); + try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+sh ")); + p.active = 0; + // A shell that is not there is said, not quietly replaced, and the + // pane that asked gets no shell of its own. + const before = p.active; + const caller = p.panes[0].?; + try std.testing.expect(p.executeBuiltinLine(0, "Tty /nonexistent")); + try std.testing.expect(p.active == before and caller.shell == null); + try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null); + try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell")); + try std.testing.expect(caller.shell == null); // Only a word that says so splits at `+`: `Dump+x.zon` is no Dump. try std.testing.expect(!p.executeBuiltinLine(0, "Dump+x.zon")); try std.testing.expect(!p.executeBuiltinLine(0, "Msg+hello")); @@ -1515,6 +1632,12 @@ test "a command pane shows how its command ended, and the next command there run const dst = exec.execute(p, 1, "make -j8") orelse return error.NoCommandPane; while (p.nextEffect()) |_| {} const pane = p.panes[dst].?; + // Its first command is echoed as every later one is. + { + const first = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(first); + try std.testing.expect(std.mem.startsWith(u8, first, "% make -j8")); + } var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) running")); @@ -1523,10 +1646,16 @@ test "a command pane shows how its command ended, and the next command there run try std.testing.expect(other != dst); while (p.nextEffect()) |_| {} p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "compiled\r\n" } }); - // Its output ending is not its end: that is its child's exit. - p.update(.{ .eof = .{ .pane = @intCast(dst) } }); - try std.testing.expect(p.panes[dst] == pane and !pane.command_done); + // Its exit comes first, a job it left still printing: done, and not + // the next command's until its pty closes. p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 2 } }); + try std.testing.expect(pane.command_done and pane.command_pty); + const busy = exec.execute(p, 1, "true") orelse return error.NoCommandPane; + try std.testing.expect(busy != dst); + while (p.nextEffect()) |_| {} + // Its output ending: now it may be reused. + p.update(.{ .eof = .{ .pane = @intCast(dst) } }); + try std.testing.expect(p.panes[dst] == pane and !pane.command_pty); try std.testing.expect(p.panes[dst] == pane); try std.testing.expect(pane.command_done); try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) exit 2")); @@ -1564,7 +1693,10 @@ test "a command pane shows how its command ended, and the next command there run const log = try ctlfs.events.freezeLog(p); defer gpa.free(log.bytes); var want: [64]u8 = undefined; - try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "run {d} make\n", .{pane.serial})) != null); + // The whole line, after the `new` of the pane it runs in. + const ran = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "run {d} make -j8\n", .{pane.serial})) orelse return error.NoRunRecord; + const made = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} ", .{pane.serial})) orelse return error.NoNewRecord; + try std.testing.expect(made < ran); try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "exit {d} 2\n", .{pane.serial})) != null); // A signal for a pane is not performed on whoever takes its slot. const Count = struct { @@ -1801,14 +1933,14 @@ test "owned cwd preserves long paths aliases and failed updates" { try std.testing.expect(p.panes[free] == null); exec.saveTo(p, 0, "file.txt"); try std.testing.expectEqual(effects, p.effects_len); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "path too long") != null); const current = pane.cwdSlice(); allocator.fail_index = allocator.alloc_index; p.setCwd(0, "/replacement"); try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "out of memory") != null); allocator.fail_index = std.math.maxInt(usize); p.setCwd(0, "/replacement"); try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); @@ -2359,7 +2491,7 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu }; const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; exec.runBuiltin(p, help, 0, "", null); - try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no pane slots") != null); } test "Font keeps requested and acknowledged faces as separate plain state" { @@ -2393,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, @@ -2499,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); @@ -3464,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()); @@ -3474,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); @@ -3513,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)); @@ -3536,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); @@ -3562,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)); @@ -3632,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); @@ -3686,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; @@ -3713,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)); } @@ -3757,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()); @@ -3792,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]; @@ -3800,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" { @@ -3955,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, @@ -4035,13 +4167,11 @@ pub const Pardes = struct { chord_arg: ?[]u8 = null, /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, - /// The exec under way was made by a gesture on a pane's body -- a - /// middle click, the execute key -- and so may go to a REPL bound for - /// the text's language; one run by name, `Exec ...`, never does. - exec_from_body: bool = false, /// An exec waiting on the REPL question (`Pane.Prompt.repl_choice`): /// the pane it came from, the language, and the text, owned. - repl_ask: ?struct { from: u32, lang: u8, text: []u8 } = null, + /// `offered` holds, by letter, the serial of the pane each answer named + /// when asked: a letter answered later is that pane or nothing. + repl_ask: ?struct { from: u32, lang: u8, text: []u8, offered: [26]u32 = @splat(0) } = null, /// A key press is being handled: someone is at the keyboard to answer, /// so a builtin may ask rather than guess. Nothing else sets it -- not /// a click, a 9P write, a startup line or a pane's shell exiting. @@ -4069,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. @@ -4444,6 +4581,7 @@ pub const Pardes = struct { errdefer p.gpa.free(owned); const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); pane.command = owned; + pane.command_pty = true; pane.body.mode = .tty; // Its directory is the one it was run for, and says which of the // directory's commands reuse it; the child's own cd does not move it. @@ -4527,6 +4665,14 @@ pub const Pardes = struct { if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]); } + /// How many bytes `emitWrite` can take for pane `id` now without + /// dropping any: the effects left, then what the pending buffer holds. + pub fn writeRoom(p: *const Pardes, id: usize) usize { + const pending = limits.pending_write_cap -| p.pending_write_bytes; + if (p.pending_write[id] != null) return pending; + return (p.effects.len - p.effects_len) * 64 + pending; + } + fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void { if (comptime limits.pending_write_cap == 0) return; if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return; @@ -4887,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(); @@ -4897,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); @@ -4921,6 +5079,10 @@ pub const Pardes = struct { look.cancelLookHover(p); const reply = ctlfs.handle(p, req); if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply); + // A pane that joined a column holding only a stillborn scratch takes + // it now, before the answer: one read of index after the look shows + // the replacement done, and the log has its del with the new. + exec.replaceStillborn(p); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -5022,6 +5184,7 @@ pub const Pardes = struct { }, .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) { // Output is over; the command is over when its child exits. + pane.command_pty = false; } else p.removePane(e.pane, null) catch |err| { pane.body.mode = .normal; p.reportError(e.pane, "terminal exited; Del retries close", err); @@ -5158,9 +5321,14 @@ pub const Pardes = struct { defer p.gpa.free(ask.text); // Its letter sends it there; anything else takes it back. if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') return; - for (p.panes, 0..) |target, to| if (target) |repl| if (repl.repl) |r| - if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(repl)) + const serial = ask.offered[key.cp - 'a']; + if (serial == 0) return; + // The pane offered, still that REPL and still running: it + // may have closed or been rebound while the question stood. + if (p.paneBySerial(serial)) |to| if (p.panes[to].?.repl) |r| + if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(p, to)) return exec.sendToRepl(p, id, to, ask.text); + p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent"); return; } if (asked.prompt != .del_side) continue; @@ -5313,9 +5481,6 @@ pub const Pardes = struct { return; } edit.pinPaneCursor(p, &pane.body); - // The execute key on a body is the exec a bound REPL may take. - p.exec_from_body = cmd == config.exec_cmd; - defer p.exec_from_body = false; const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active; if (explicit) { @@ -5324,13 +5489,14 @@ pub const Pardes = struct { pane.body.vsel.active = false; pane.body.msel.active = false; pane.body.select = false; - exec.runBuiltin(p, cmd, p.active, "", txt); + // The execute key on a body: text a bound REPL may take. + exec.runBuiltinFrom(p, cmd, p.active, "", txt, true); return; } } const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return; const word = edit.selectionText(p, pane, sel) catch return; - exec.runBuiltin(p, cmd, p.active, "", word); + exec.runBuiltinFrom(p, cmd, p.active, "", word, true); return; } if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); @@ -5710,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| { @@ -5727,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 ---- @@ -5772,7 +5938,10 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- /// Close a pane; its rows go to `to`, or where layout.absorbVWeight - /// sends them when that is null. + /// sends them when that is null. Focus on it follows its rows: to the + /// pane that grew into them, or to the scratch left standing in an + /// emptied column. Every route that closes a pane comes through here or + /// removeColumn, so none picks focus for itself, and none by recency. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { const pane = p.panes[id] orelse return; // A column outlives its panes. Closing the last one leaves an empty @@ -5799,10 +5968,11 @@ pub const Pardes = struct { layout.insert(p, f.col, f.idx + 1, free); break :heir free; }; - layout.absorbVWeight(p, id, to); + const gainer = layout.absorbVWeight(p, id, to); + const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null; layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse { + if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse { p.quit = true; p.emit(.quit); return; @@ -5811,6 +5981,11 @@ pub const Pardes = struct { pub fn removeColumn(p: *Pardes, id: usize) !void { const place = layout.findPane(p, id) orelse return; + const had_focus = layout.findPane(p, p.active); + const beside = if (had_focus != null and had_focus.?.col == place.col) + p.besideColumn(place.col, p.active) + else + null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; const count = p.col_n[place.col]; @@ -5824,7 +5999,7 @@ pub const Pardes = struct { layout.removePane(p, closed); p.panes[closed] = null; } - if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse { p.quit = true; p.emit(.quit); return; @@ -5847,16 +6022,26 @@ pub const Pardes = struct { return null; } - pub fn prevFocus(p: *Pardes, closing: usize) ?usize { - var i = p.njumps; - while (i > 0) { - i -= 1; - const id = p.jumps[i].pane; - if (id != closing and p.panes[id] != null) return id; - } - for (p.panes, 0..) |slot, k| { - if (slot != null and k != closing) return k; + /// The pane that takes over `id`'s place when column `col` goes: in the + /// column layout.removePane gives the width to, the one level with the + /// middle of `id`. Asked before the column goes, while rects still hold. + fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize { + if (p.ncol < 2) return null; + const next = if (col > 0) col - 1 else col + 1; + const r = p.rects[id]; + const mid = r.y + r.h / 2; + var pick: ?usize = null; + for (p.col_panes[next][0..p.col_n[next]]) |k| { + if (p.panes[k] == null) continue; + pick = k; + if (p.rects[k].y +| p.rects[k].h > mid) break; } + return pick; + } + + /// Any live pane: the last resort when nothing took the closed one's place. + fn anyPane(p: *const Pardes) ?usize { + for (p.panes, 0..) |slot, k| if (slot != null) return k; return null; } @@ -6019,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; @@ -6047,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; } @@ -6073,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 ---- + // ---- render: build the canonical surface (draw.zig) ---- - 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 }; - } - } - - 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; @@ -6751,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. @@ -6802,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); @@ -6904,6 +6728,7 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { // A word that is not a side closes nothing. try std.testing.expect(p.executeBuiltinLine(again, "Del x")); + try std.testing.expectEqualStrings("Del: takes k, j or nothing", p.panes[again].?.msg[0..p.panes[again].?.msg_len]); try std.testing.expect(p.panes[again] != null); // DelAbove is Del k, and a line that is not a key never asks. try std.testing.expect(p.executeBuiltinLine(again, "DelAbove")); @@ -6931,6 +6756,59 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { try std.testing.expect(p.panes[low] == null); } +test "a Look that finds nothing says so" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.newScratchBelow(p.active); + const id = p.active; + p.sync(); + look.lookAt(p, id, "/no/such/zzqq"); + try std.testing.expectEqualStrings("Look: no match for \"/no/such/zzqq\"", p.panes[id].?.msg[0..p.panes[id].?.msg_len]); +} + +test "focus on a closed pane goes to the pane that takes its place, not the one focused before" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); + defer p.deinit(); + const shell = p.active; + p.newScratchColumn(shell); + const right = p.active; + p.sync(); + p.newScratchBelow(shell); + const low = p.active; + p.sync(); + // The dogfood case: `right` was focused just before `low`, but `shell` + // grows into `low`'s rows, so `shell` gets the focus. + try std.testing.expect(p.executeBuiltinLine(low, "Del")); + p.sync(); + try std.testing.expect(p.panes[low] == null); + try std.testing.expectEqual(shell, p.active); + + // Closing a pane that has no focus leaves the focus where it is. + p.newScratchBelow(shell); + const other = p.active; + p.sync(); + p.active = right; + p.sync(); + try std.testing.expect(p.executeBuiltinLine(other, "Del")); + try std.testing.expectEqual(right, p.active); + + // Delcol: the column that takes the width holds, level with the closed + // pane, the pane that takes its place; `right` was focused just before. + p.newScratchBelow(right); + const right_low = p.active; + p.newScratchBelow(shell); + const left_low = p.active; + p.active = right; + p.sync(); + p.active = left_low; + p.sync(); + try p.removeColumn(left_low); + p.sync(); + try std.testing.expectEqual(right_low, p.active); +} + test "closing the last pane in a column leaves an empty one in its place" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); @@ -7084,7 +6962,7 @@ test "raw tty Escape only leaves at a shell prompt" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); - exec.spawnTty(p, 0); + _ = exec.spawnTty(p, 0); const shell = p.active; const pane = p.panes[shell].?; var buf: [256]u8 = undefined; @@ -7115,7 +6993,7 @@ test "raw tty Ctrl-B switches to editor mode without sending child input" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" }); defer p.deinit(); - exec.spawnTty(p, 0); + _ = exec.spawnTty(p, 0); const shell = p.active; p.sync(); var buf: [256]u8 = undefined; @@ -7478,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(); @@ -7591,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()); +} |
