//! /screen, /listeners and the snapshots that freeze a screen or a terminal //! history between open and close (tree.zig's open records hold them). const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); const tree = @import("tree.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const Req = tree.Req; const Reply = tree.Reply; const E = tree.E; pub fn render(p: *Pardes) ![]u8 { var arena: std.heap.ArenaAllocator = .init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); var styles: std.ArrayList(pardes.CellStyle) = .empty; var indices: std.ArrayList(usize) = .empty; for (surface.cells) |cell| { const style: pardes.CellStyle = if (cell.default) .{} else cell.style; const index = for (styles.items, 0..) |previous, i| { if (std.meta.eql(style, previous)) break i; } else new: { try styles.append(arena.allocator(), style); break :new styles.items.len - 1; }; try indices.append(arena.allocator(), index); } var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); const writer = &out.writer; try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows }); try std.json.Stringify.value(surface.cursor, .{}, writer); try writer.writeAll(",\"styles\":"); try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer); try writer.writeAll(",\"cells\":["); for (surface.cells, indices.items, 0..) |cell, index, i| { if (i != 0) try writer.writeByte(','); try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer); } try writer.writeAll("]}\n"); return out.toOwnedSlice(); } pub fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { const snapshot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.snapshot; if (snapshot.* == null) { const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); snapshot.* = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); } const bytes = snapshot.*.?; const off = @min(req.off, bytes.len); const len = @min(bytes.len - off, req.size); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; } pub const listeners_cap = 512; pub fn listenersText(p: *Pardes, buf: []u8) []const u8 { var text = std.Io.Writer.fixed(buf); if (p.fs.socket_path.len != 0) text.print("unix!{s}\n", .{p.fs.socket_path}) catch return text.buffered(); for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { const address = maybe orelse continue; switch (address) { .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch return text.buffered(), .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch return text.buffered(), } } return text.buffered(); } pub fn readListeners(p: *Pardes, req: Req) Reply { var buf: [listeners_cap]u8 = undefined; const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, listenersText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } // ---- tests ---- const testing = std.testing; const th = @import("testing.zig"); const fs = @import("../fs.zig"); const call = th.call; const rd = th.rd; const wr = th.wr; const withFile = th.withFile; const withTerm = th.withTerm; const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; const screen_node = @intFromEnum(tree.TopFile.screen); test "listener addresses are readable canonical dials with bounded partial reads" { const gpa = testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); p.fs.socket_path = "/tmp/a socket.sock"; p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; p.fs.quic_address = .{ .ip6 = .loopback(5641) }; const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n"; for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| { const bytes = try fs.read(p, path); defer gpa.free(bytes); try testing.expectEqualStrings(expected, bytes); try testing.expectError(error.ReadOnlyFilesystem, fs.write(p, path, "")); } const node = @intFromEnum(tree.TopFile.listeners); var off: usize = 0; while (off < expected.len) { const part = rd(p, node, off, 3); try testing.expectEqual(Status.ok, part.reply.status); try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes); off += part.bytes.len; } try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len); } test "terminal body handles keep one history snapshot across fragmented reads" { const gpa = testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const node = Node.of(serialOf(p), .body); p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } }); while (p.nextEffect()) |_| {} const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa); defer gpa.free(original); const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Status.ok, opened.reply.status); const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 }); try testing.expectEqualStrings(original[0..3], first.bytes); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } }); while (p.nextEffect()) |_| {} var offset: usize = 3; while (offset < original.len) { const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 }); try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes); offset += part.bytes.len; } try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes); _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle }); const newer = call(p, .{ .tag = 6, .op = .open, .node = node }); try testing.expectEqualStrings("new output\n", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle }); } test "an empty terminal body snapshot stays empty while output continues" { const p = try withTerm(std.testing.allocator); defer p.deinit(); const node = Node.of(serialOf(p), .body); const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); try testing.expect(p.fs.opens[opened.reply.handle - 1].what.snapshot != null); p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } }); while (p.nextEffect()) |_| {} try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); const newer = call(p, .{ .tag = 5, .op = .open, .node = node }); try testing.expectEqualStrings("new output\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle }); } test "terminal body snapshots are lazy bounded and released after the pane closes" { const gpa = testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const node = Node.of(serialOf(p), .body); var handles: [tree.open_slots]u32 = undefined; for (&handles) |*opened_handle| { const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Status.ok, opened.reply.status); opened_handle.* = opened.reply.handle; } for (p.fs.opens) |o| { try testing.expectEqual(node, o.node); try testing.expect(o.what.snapshot == null); } try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno()); const scratch = try th.newPane(p); const scratch_id = p.paneBySerial(scratch).?; try testing.expect(p.panes[scratch_id].?.file != null); p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } }); while (p.nextEffect()) |_| {} var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.gpa = failing.allocator(); const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 }); p.gpa = gpa; try testing.expectEqual(E.NOMEM, failed.errno()); try testing.expect(p.fs.opens[handles[0] - 1].what.snapshot == null); const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 }); try testing.expectEqualStrings("latest output\n", first.bytes); const saved = p.fs.opens[handles[0] - 1].what.snapshot.?; failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.gpa = failing.allocator(); const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 }); p.gpa = gpa; try testing.expectEqualStrings("output\n", second.bytes); try testing.expect(!failing.has_induced_failure); try testing.expectEqual(saved.ptr, p.fs.opens[handles[0] - 1].what.snapshot.?.ptr); try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen_node, .handle = handles[0], .size = 32 }).errno()); _ = call(p, .{ .tag = 7, .op = .release, .node = screen_node, .handle = handles[0] }); try testing.expectEqual(node, p.fs.opens[handles[0] - 1].node); try p.removePane(0, null); for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle }); for (p.fs.opens) |o| try testing.expectEqual(@as(u64, 0), o.node); } test "screen snapshots preserve rendered cells and styles until their handle is released" { const gpa = testing.allocator; const p = try withFile(gpa, "const value = 1;\n"); defer p.deinit(); const opened = call(p, .{ .tag = 1, .op = .open, .node = screen_node }); try testing.expectEqual(Status.ok, opened.reply.status); const snapshot = p.fs.opens[opened.reply.handle - 1].what.snapshot.?; const original = try gpa.dupe(u8, snapshot); defer gpa.free(original); const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{}); defer parsed.deinit(); const data = parsed.value.object; try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer); try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer); try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len); try testing.expect(data.get("styles").?.array.items.len > 0); _ = wr(p, Node.of(serialOf(p), .body), "changed\n"); const newer = call(p, .{ .tag = 2, .op = .open, .node = screen_node }); try testing.expectEqual(Status.ok, newer.reply.status); try testing.expect(!std.mem.eql(u8, original, p.fs.opens[newer.reply.handle - 1].what.snapshot.?)); var off: usize = 0; while (off < original.len) { const result = call(p, .{ .tag = 3, .op = .read, .node = screen_node, .handle = opened.reply.handle, .off = off, .size = 13 }); try testing.expectEqual(Status.ok, result.reply.status); try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes); off += result.bytes.len; } _ = call(p, .{ .tag = 4, .op = .release, .node = screen_node, .handle = opened.reply.handle }); _ = call(p, .{ .tag = 5, .op = .release, .node = screen_node, .handle = newer.reply.handle }); for (p.fs.opens) |o| try testing.expect(o.node == 0); } test "screen inspection preserves acknowledged presentation and the next real frame" { const gpa = testing.allocator; for (std.enums.values(pardes.animation.Transition)) |transition| { errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)}); const control = try withFile(gpa, "const value = 1;\n"); defer control.deinit(); const inspected = try withFile(gpa, "const value = 1;\n"); defer inspected.deinit(); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); for ([_]*Pardes{ control, inspected }) |p| { // The mechanism, at a fixed flavour, not the default's tuning. p.settings.motion = .crisp; p.settings.panel_transition = .off; p.update(.tick); p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); p.settings.panel_transition = transition; } for (0..4) |phase| { for ([_]*Pardes{ control, inspected }) |p| switch (phase) { 0 => {}, 1 => p.update(.{ .command = "New" }), 2 => { p.update(.tick); try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status); }, 3 => { for (0..transition.frames() + 1) |_| p.update(.tick); p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); p.update(.{ .command = "Del" }); }, else => unreachable, }; for (0..2) |_| { const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen_node }); try testing.expectEqual(Status.ok, opened.reply.status); const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen_node, .handle = opened.reply.handle, .size = 32 }); try testing.expectEqual(Status.ok, captured.reply.status); try testing.expect(captured.bytes.len > 0); try testing.expectEqual(Status.ok, call(inspected, .{ .tag = 3, .op = .release, .node = screen_node, .handle = opened.reply.handle, }).reply.status); } const a = &control.presentation; const b = &inspected.presentation; try testing.expectEqualDeep(a.shown, b.shown); try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks); try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]); try testing.expectEqual(a.pending, b.pending); try testing.expectEqual(a.acknowledged, b.acknowledged); try testing.expectEqual(a.previous_valid, b.previous_valid); try testing.expectEqual(a.previous_cols, b.previous_cols); try testing.expectEqual(a.previous_rows, b.previous_rows); try testing.expectEqualDeep(a.previous_layout, b.previous_layout); try testing.expectEqual(a.previous_cells.len, b.previous_cells.len); for (a.previous_cells, b.previous_cells) |*expected, *actual| try testing.expect(expected.visuallyEqual(actual)); const expected = try control.render(arena.allocator()); const actual = try inspected.render(arena.allocator()); try testing.expectEqual(expected.cols, actual.cols); try testing.expectEqual(expected.rows, actual.rows); try testing.expectEqualDeep(expected.cursor, actual.cursor); try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks()); try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs); try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len); try testing.expectEqual(expected.cells.len, actual.cells.len); try testing.expectEqual(@as(usize, 0), expected.nimages); try testing.expectEqual(@as(usize, 0), actual.nimages); for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0); if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff()); if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0); control.acknowledgePanelPresentation(expected.panelTracks()); inspected.acknowledgePanelPresentation(actual.panelTracks()); try testing.expectEqualDeep(a.shown, b.shown); try testing.expectEqualDeep(a.previous_layout, b.previous_layout); _ = arena.reset(.retain_capacity); } } } test "a terminal's body is its logical lines: a row the terminal wrapped is joined again" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const pane = p.panes[0].?; const long = "w" ** 50 ++ "x" ** 150; // wider than the pane: it wraps try testing.expect(long.len > pane.cols); p.update(.{ .output = .{ .pane = 0, .bytes = long ++ "\r\nnext" } }); while (p.nextEffect()) |_| {} const node = Node.of(pane.serial, .body); const body = th.rd(p, node, 0, 4096).bytes; try testing.expectEqualStrings(long ++ "\nnext\n", body); try testing.expectEqual(@as(u64, body.len), th.call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); }