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"); pub const look = @import("look.zig"); pub const filesystem = @import("fs.zig"); pub const ctlfs = @import("ninep/tree.zig"); pub const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); const locations = @import("locations.zig"); pub const tagline = @import("tagline.zig"); pub const exec = @import("exec.zig"); const recent_files = @import("recent.zig"); 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; pub const themes = colors.themes; pub const native_theme_count = colors.native_theme_count; pub const ChromeTheme = colors.ChromeTheme; /// The screen types live in surface.zig; every shell names them through here. pub const Color = @import("surface.zig").Color; pub const FontRole = @import("surface.zig").FontRole; pub const CellStyle = @import("surface.zig").CellStyle; pub const Cell = @import("surface.zig").Cell; pub const AsciiDiff = @import("surface.zig").AsciiDiff; pub const PanelCellDiff = @import("surface.zig").PanelCellDiff; pub const ImageCacheKey = @import("surface.zig").ImageCacheKey; 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"); pub const config = @import("config.zig"); pub const pdf_enabled = panes.Pdf.enabled; pub const pdf = panes.Pdf.pdf; pub const memory = @import("memory.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); const host_io = @import("host_io.zig"); pub const Host = host_io.Host; pub const frameMark = tracy.frameMark; pub const Platform = enum { tty, gui, web, macos, esp32p4 }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); pub const theme_animation = @import("pardes_config").theme_animation; pub const version = @import("pardes_config").version; pub const commit: ?[]const u8 = @import("pardes_config").commit; pub const isolated = @import("pardes_isolation").isolated; pub const font_picker = platform == .gui or platform == .macos; pub const hosted = platform == .tty or platform == .gui or platform == .macos; /// Whose turn it is with the core. The core is single-threaded: the editor's /// thread has it, and lets go of it in two kinds of gap -- while it waits for /// input (`rest`, `wake`), and while it is out in the host filesystem in the /// middle of a step (`yield`, `back`). A 9P connection task takes the turn /// in those gaps to answer a request (`take`, `give`), which is what lets the /// editor read its own tree through a mount: the syscall goes out, another /// thread answers it, the syscall comes back. A step of a connection task's /// own -- a Look written to `look` -- goes out the same way. /// /// `out` counts the steps that are out. While one is, the core reads /// consistently but that step still holds pointers into it, so nothing may /// change it: a request that would is parked in the engine (src/9p_io.zig) /// and retried when the turn is next given up with nothing out, which /// `parked` remembers to arrange; and the editor's own wake waits for the /// count to reach zero, since a step of the editor's may change anything. /// It is never a write of its own that a step waits on out there -- writes /// come from a shell performing a save between steps -- so a parked request /// is never the syscall's own. /// /// One per process, like the editor it belongs to. A process that never /// starts it -- a test, the board, the browser -- has no second thread, and /// every yield is a no-op. pub var turn: Turn = .{}; pub const Turn = struct { mutex: std.Io.Mutex = .init, /// Set by `start`; a process that never starts the turn has none. io: ?std.Io = null, /// Steps out in a host syscall, from any thread. out: u32 = 0, /// A request that changes a pane was parked for want of quiet. parked: bool = false, wake_parked: ?*const fn (?*anyopaque) void = null, /// Answers the reads the core holds, with the turn, as it is given up. answer_held: ?*const fn (?*anyopaque) void = null, wake_ctx: ?*anyopaque = null, /// Bumped each time the editor has performed what the core asked of it, /// so a request that asked for something -- a save, a shell -- can be /// answered once it is done rather than merely queued, the way writing /// `put` to acme's ctl returns after the file is written. epoch: u64 = 0, /// Bumped each time a shell has attempted the Restore the core asked for /// (`takeRestore`), which lives after `pump` because it swaps the core /// out from under it; a request that asked for one waits for this. restores: u64 = 0, /// Bumped each time a language server's answer (or its lack) is taken /// in (`lspResponse`): a write that asked one waits for this. lsp_answers: u64 = 0, /// All of the above, and `out` reaching zero. settled: std.Io.Condition = .init, /// The editor's thread takes the turn for good; it has it until `stop`. pub fn start(t: *Turn, io: std.Io) void { t.io = io; t.mutex.lockUncancelable(io); } pub fn stop(t: *Turn) void { const io = t.io orelse return; t.io = null; t.parked = false; t.mutex.unlock(io); } /// The editor is about to wait for input: anyone may have the core. pub fn rest(t: *Turn) void { if (t.io == null) return; t.release(); } /// The editor takes the turn back -- once every step that is out in a /// syscall has returned, since a step of the editor's may change what /// those still point into. pub fn wake(t: *Turn) void { const io = t.io orelse return; t.mutex.lockUncancelable(io); while (t.out != 0) t.settled.waitUncancelable(io, &t.mutex); } /// Whoever has the turn is about to block in the host in the middle of /// a step. Nests: a directory listing that resolves each entry yields /// inside a yield, and only the outermost one lets go. pub fn yield(t: *Turn) void { const io = t.io orelse return; yield_depth += 1; if (yield_depth != 1) return; t.out += 1; t.mutex.unlock(io); } pub fn back(t: *Turn) void { const io = t.io orelse return; yield_depth -= 1; if (yield_depth != 0) return; t.mutex.lockUncancelable(io); t.out -= 1; if (t.out == 0) t.settled.broadcast(io); } /// How many yields this thread is inside of. threadlocal var yield_depth: u32 = 0; /// A connection task takes the turn; answers whether nothing is out /// mid-step, which is what a request that changes a pane needs. pub fn take(t: *Turn) bool { t.mutex.lockUncancelable(t.io.?); return t.out == 0; } pub fn give(t: *Turn) void { t.release(); } /// The editor performed the core's effects. pub fn settle(t: *Turn) void { const io = t.io orelse return; t.epoch +%= 1; t.settled.broadcast(io); } /// With the turn: waits, turn given up meanwhile, until the editor has /// performed past `epoch`; stopping ends the wait early. pub fn awaitSettled(t: *Turn, epoch: u64) void { while (t.epoch == epoch) t.settled.wait(t.io.?, &t.mutex) catch return; } /// The shell attempted the Restore the core asked for, whichever way it /// went; called at the top of each loop step, when the previous step's /// attempt is over. pub fn restoreSettled(t: *Turn) void { const io = t.io orelse return; t.restores +%= 1; t.settled.broadcast(io); } pub fn lspAnswered(t: *Turn) void { const io = t.io orelse return; t.lsp_answers +%= 1; t.settled.broadcast(io); } pub fn awaitLsp(t: *Turn, answers: u64) void { while (t.lsp_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return; } pub fn awaitRestored(t: *Turn, restores: u64) void { while (t.restores == restores) t.settled.wait(t.io.?, &t.mutex) catch return; } fn release(t: *Turn) void { // Whatever the holder just did may be what a held read waits for, // and answering it takes the core, so before letting go. Even with // a step out in a syscall: a read is served then, as in onServe. if (t.answer_held) |f| f(t.wake_ctx); const woken = t.out == 0 and t.parked; if (woken) t.parked = false; t.mutex.unlock(t.io.?); if (woken) if (t.wake_parked) |f| f(t.wake_ctx); } }; pub const can_attach = platform == .tty or platform == .gui; pub const terminal_panes = platform != .esp32p4; pub const fonts = if (font_picker) @import("fonts.zig") else struct {}; pub const PdfRasterPolicy = panes.Pdf.RasterPolicy; pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{ .dpi = 96, .max_dimension = 1200, .match_viewport = false, }; pub const sdl_pdf_raster_policy: PdfRasterPolicy = .{ .dpi = 192, .max_dimension = 4096, .match_viewport = true, }; pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { .tty => kitty_pdf_raster_policy, // Both pixel shells rasterize for a real display and can afford it; the // wire-bandwidth argument that shapes the Kitty policy does not apply. .gui, .macos => sdl_pdf_raster_policy, // Neither hostless platform rasterizes a PDF at all (mupdf is compiled // out), so the cheaper policy is the honest placeholder. .web, .esp32p4 => kitty_pdf_raster_policy, }; pub const Pane = panes.Pane; const Text = panes.Text; /// Pane slots: 64 on a desktop, where a session keeps many files and /// terminals open; the board keeps its 16. A slot costs its share of the /// fixed tables (MAX_PANES-sized arrays in the core and its surface) whether /// or not a pane is in it; a pane itself is allocated when it opens. pub const MAX_PANES = if (platform == .esp32p4) 16 else 64; 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; /// 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; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; /// Columns a session holds: 16 on a desktop (each costs about 8 KB of state, /// a column tag being most of it), 6 on the board. pub const MAX_COLS = if (platform == .esp32p4) 6 else 16; const column_weight_unit = layout.column_weight_unit; const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const COLBAR_H: u16 = 1; pub const BOX_H: u16 = 1; pub const TAG_GAP: u16 = 1; pub const TAG_TEXT_INSET: u16 = config.GUTTER + TAG_GAP; pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; const rows: u32 = @max(1, @as(u32, @intFromFloat(@floor(canvas_h / @as(f32, @floatFromInt(cell_h)))))); // Every band centers in its cell, so text and anchors sit at the same // height in the workspace, column and pane tags alike. Only the last row // faces the window edge (Tagbottom). if (@as(u32, row) + 1 >= rows) return spare; return spare / 2; } pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); } 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| if (col >= x and col - x < w) return @floatFromInt(x); } if (track) |active| { const box = active.contentBox(); const rows: f32 = @floatFromInt(if (active.pane < MAX_PANES) if (p.panes[active.pane]) |pane| pane.tag_rows else BOX_H else BOX_H); const tag_y = if (p.settings.tag_bottom) box.y + box.h - rows else box.y; if (@as(f32, @floatFromInt(row)) >= tag_y and @as(f32, @floatFromInt(row)) < tag_y + rows) return box.x; } for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; const tag_y = p.tagTop(pane, r); if (row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x); } return @floatFromInt(col); } pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { for (p.surface.panelTracks()) |track| if (track.contentBox().contains(col, row)) return taglineOriginCol(p, col, row, track); return taglineOriginCol(p, col, row, null); } test "paint order is moving, then opening, then closing tombstones on top" { 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{ .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide }, .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom }, null, .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii }, .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve }, }; const closing = [_]Track{ .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, }; var out: [8]Track = undefined; const len = animation.paintOrder(&live, &closing, &out); var serials: [8]u32 = undefined; for (out[0..len], 0..) |track, i| serials[i] = track.serial; try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, serials[0..len]); // 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), 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); } pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const body = @max(body_w, 1); const tag = @max(tagline_w, 1); if (row < (if (p) |core| core.topBarHeight() else TOPBAR_H)) return colFromSpan(x, tag); if (p) |core| if (row < core.topBarHeight() + core.columnBarHeight()) { for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |origin, width| { if (width == 0) continue; const left = @as(f32, @floatFromInt(origin)) * body; const right = @as(f32, @floatFromInt(origin + width)) * body; if (x < left or x >= right) continue; return origin + @as(u16, @intFromFloat(@min( @floor(@max(0, x - left) / tag), @as(f32, @floatFromInt(width - 1)), ))); } }; if (p) |core| for (core.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = core.rects[id]; const tag_y = core.tagTop(pane, r); if (row < tag_y or row >= tag_y + pane.tag_rows or r.w == 0) continue; const left = @as(f32, @floatFromInt(r.x)) * body; const right = @as(f32, @floatFromInt(r.x + r.w)) * body; if (x < left or x >= right) continue; const within: u16 = @intFromFloat(@min( @floor(@max(0, x - left) / tag), @as(f32, @floatFromInt(r.w - 1)), )); return r.x + within; }; return colFromSpan(x, body); } fn colFromSpan(x: f32, span: f32) u16 { return @intFromFloat(@min(@floor(@max(x, 0) / span), 10_000)); } test "a compact tagline anchors at its own pane, and both shells step from the same origin" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); // Two panes side by side put two tags on ONE row, which is the case a // per-row origin gets wrong and the reason this is asked per cell. const left = p.rects[0]; const right = p.rects[1]; try std.testing.expect(right.x > left.x); try std.testing.expectEqual(@as(f32, @floatFromInt(left.x)), taglineOriginCol(p, left.x + 3, left.y, null)); try std.testing.expectEqual(@as(f32, @floatFromInt(right.x)), taglineOriginCol(p, right.x + 3, right.y, null)); // The topbar's origin is column zero always — it is not a pane rect, which // is why row zero is right even in a window with no core to ask. try std.testing.expectEqual(@as(f32, 0), taglineOriginCol(p, 40, 0, null)); const body_row = left.y + 2; try std.testing.expectEqual(@as(f32, 7), taglineOriginCol(p, 7, body_row, null)); const body_w: f32 = 10; const tag_w: f32 = 8; for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |origin, width| { if (width == 0) continue; const col = origin; try std.testing.expectEqual(@as(f32, @floatFromInt(origin)), taglineOriginCol(p, col, TOPBAR_H, null)); const glyph_x = @as(f32, @floatFromInt(origin)) * body_w; try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, TOPBAR_H, body_w, tag_w)); } for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { const origin = taglineOriginCol(p, col, left.y, null); const sdl = origin * (body_w - tag_w) + @as(f32, @floatFromInt(col)) * tag_w; const appkit = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w; try std.testing.expectEqual(sdl, appkit); } for ([_]u16{ 0, 1, 4, 9 }) |offset| { const col = left.x + offset; if (offset >= left.w) break; const origin = taglineOriginCol(p, col, left.y, null); const glyph_x = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w; try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, left.y, body_w, tag_w)); } // The topbar's pointer grid is compact from column zero, with no pane to // anchor to — the one tag row that is right with or without a core. try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * tag_w, 0, body_w, tag_w)); try std.testing.expectEqual(@as(u16, 3), gridColAt(null, 3.5 * tag_w, 0, body_w, tag_w)); // A body row is untouched: still the body grid, still divided by the body // cell. Only tag rows compact. try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w)); } const recent_path_cap = limits.host_path_cap; pub const Loc = struct { pane: u16, /// 0 once its pane has closed: a file's entry stays, reopened by a /// jump to it from `path_hash`'s recent entry. serial: u32, line: u32, col: u32, /// The file it is in (Wyhash of the path), 0 for a pane with none. path_hash: u64 = 0, }; test { _ = @import("tagline.zig"); _ = @import("exec.zig"); _ = @import("look.zig"); _ = @import("sam_edit.zig"); _ = @import("mouse.zig"); _ = @import("normal.zig"); _ = @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("dump.zig"); // its tests ran nowhere before _ = @import("recent.zig"); _ = @import("Presentation.zig"); _ = @import("animation.zig"); } pub const Builtin = builtins.registry.Builtin(); pub const test_api = if (@import("builtin").is_test) struct { pub fn runBuiltin( p: *Pardes, name: []const u8, id: usize, txt: []const u8, arg: ?[]const u8, ) bool { const command = std.meta.stringToEnum(Builtin, name) orelse return false; exec.runBuiltin(p, command, id, txt, arg); return true; } pub fn sync(p: *Pardes) void { p.sync(); } pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool { return look.searchStep(p, id, delta); } pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { return exec.heldSelection(p, first); } } else struct {}; fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { var len: ?usize = null; while (p.nextEffect()) |effect| { switch (effect) { .save_text => |st| { const path = st.path.slice(); @memcpy(buf[0..path.len], path); len = path.len; }, else => {}, } p.perform(effect); } return if (len) |n| buf[0..n] else null; } fn drainForAttach(p: *Pardes) ?AttachRequest { while (p.nextEffect()) |effect| p.perform(effect); return p.takeAttach(); } test "Attach asks for a session and tears nothing down" { if (comptime !can_attach) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} // Bare means whichever session is there, and that is an EMPTY name rather // than an absent request: the frontend still has to go and look. try std.testing.expect(p.executeBuiltinLine(0, "Attach")); const bare = drainForAttach(p) orelse return error.NoAttachAsked; try std.testing.expectEqualStrings("", bare.name); try std.testing.expectEqual(@as(u8, 0), bare.pane); // ...and the named form carries its tail, the way `Theme ` does. try std.testing.expect(p.executeBuiltinLine(0, "Attach work")); const named = drainForAttach(p) orelse return error.NoAttachAsked; try std.testing.expectEqualStrings("work", named.name); try std.testing.expect(p.takeAttach() == null); // taken once, then gone // The word only ASKS. A core that tore itself down here could not be handed // back intact when the connect fails, and that is the entire guarantee. try std.testing.expect(!p.quit); try std.testing.expect(p.panes[0] != null); } test "Attach in a detached session: bare is wrong #args, a name no session has says so, and nothing is asked" { if (comptime !can_attach) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .detached_session = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; try std.testing.expect(p.executeBuiltinLine(0, "Attach")); try std.testing.expect(drainForAttach(p) == null); try std.testing.expectStringStartsWith(pane.msg[0..pane.msg_len], "wrong #args"); try std.testing.expect(p.executeBuiltinLine(0, "Attach zz-no-such-session-qq")); try std.testing.expect(drainForAttach(p) == null); try std.testing.expectEqualStrings("Attach: zz-no-such-session-qq: no such session", pane.msg[0..pane.msg_len]); } test "Detach asks the frontend to leave, and says so when there is nothing to leave" { if (comptime !can_attach) return; // compiled out where nothing polls `takeAttach` — see `can_attach` const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(0, "Detach")); const asked = while (p.nextEffect()) |effect| switch (effect) { .detach => |d| break d, else => {}, } else return error.NoDetachAsked; try std.testing.expectEqual(@as(u8, 0), asked.pane); p.perform(.{ .detach = asked }); const pane = p.panes[0].?; try std.testing.expectEqualStrings("detach: not attached", pane.msg[0..pane.msg_len]); try std.testing.expect(!p.quit); } test "startup config runs builtin lines in order and isolates bad lines" { const p = try Pardes.init(std.testing.allocator, .{ .startup_config = \\Theme dark \\DefinitelyNotABuiltin \\Exit trailing-garbage \\Theme no-such-theme \\Theme acme , }); defer p.deinit(); try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); try std.testing.expect(!p.quit); for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) { .write => |w| { try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "DefinitelyNotABuiltin") == null); try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Exit") == null); }, else => {}, }; } 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.settings.post.set(.crt, "", true)); try std.testing.expect(!p.animationActive()); } test "topbar hover highlights exactly the executable word" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); _ = try p.render(frame.allocator()); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 1, .row = 0 } }); _ = frame.reset(.retain_capacity); const hovered = try p.render(frame.allocator()); for (0.."Newcol".len) |col| { const cell = hovered.at(@intCast(col), 0); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, cell.style.bg); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_fg }, cell.style.fg); } try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(6, 0).style.bg); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 6, .row = 0 } }); _ = frame.reset(.retain_capacity); const whitespace = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, whitespace.at(1, 0).style.bg); p.update(.pointer_leave); _ = frame.reset(.retain_capacity); const left = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, left.at(1, 0).style.bg); } test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // initial shell spawn/geometry p.acknowledgeShell(0, "/bin/sh", false); // The Tty word names the shell the host ran, and opens another of it. const pane = p.panes[0].?; try std.testing.expectEqualStrings(" Tty+sh" ++ tagline.terminal_pane_tail[" Tty".len..], tagline.curTail(pane)); const tag_x = p.rects[0].x + TAG_TEXT_INSET; const tag_y = p.rects[0].y; const rendered = try tagline.tagText(p, p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "Tty").?)) + 1; var panes_before: usize = 0; for (p.panes) |s| if (s != null) { panes_before += 1; }; p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = new_x, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = new_x, .row = tag_y } }); while (p.nextEffect()) |effect| switch (effect) { .write => return error.NewFellThroughToPty, else => {}, }; var panes_after: usize = 0; for (p.panes) |s| if (s != null) { panes_after += 1; }; try std.testing.expectEqual(panes_before + 1, panes_after); try std.testing.expect(p.panes[p.active].?.isTerminal()); try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?); // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = try p.gpa.dupe(u8, " Theme dark"); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 10, .row = tag_y } }); try std.testing.expectEqualStrings("dark", p.theme().name); while (p.nextEffect()) |effect| switch (effect) { .write => return error.ArgumentBuiltinFellThroughToPty, else => {}, }; // Fixed-name builtins remain exact: a tail does not turn New or Exit into // a match (especially important for the destructive one). try std.testing.expect(!p.executeBuiltinLine(0, "New argument")); try std.testing.expect(!p.executeBuiltinLine(0, "Exit trailing-garbage")); try std.testing.expect(!p.quit); // A word absent from the builtin vocabulary takes the fallback, including // the terminating carriage return expected by the shell prompt. if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = try p.gpa.dupe(u8, " DefinitelyNotABuiltin"); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } }); var sent: [256]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, else => {}, }; try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); } test "Collapse builtin keeps content and restores the pane from its default tag" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 24, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("keep this text\n"); p.sync(); const height = p.rects[0].h; const revision = pane.file.?.revision; for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.terminal_pane_tail }) |tail| try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse Del")); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); p.sync(); try std.testing.expect(pane.collapsed); try std.testing.expectEqual(@as(u16, BOX_H), p.rects[0].h); try std.testing.expectEqual(@as(f32, @floatFromInt(height)), pane.vweight); try std.testing.expectEqual(revision, pane.file.?.revision); try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content); try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null); tagline.enterTag(p, pane); const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor; try std.testing.expectEqual(p.rects[0].y, tag_cursor.y); pane.focus = .body; try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); p.sync(); try std.testing.expect(!pane.collapsed); try std.testing.expectEqual(height, p.rects[0].h); } test "LocationsConfig command reports partial updates and survives restore" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("source\n"); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:5 tscontext:on")); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig tslocations:off")); const expected: locations.Config = .{ .context = 5, .tscontext = true, .tslocations = false }; try std.testing.expectEqual(expected, p.locations_config); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on")); try std.testing.expectEqual(expected, p.locations_config); // Setting it is silent; asking bare is what opens the window. try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig")); var reports: usize = 0; for (p.panes) |slot| { const pane = slot orelse continue; const file = pane.file orelse continue; const out = file.output orelse continue; if (!std.meta.eql(out.from, panes.Output.Origin{ .cmd = .LocationsConfig })) continue; reports += 1; try std.testing.expectEqualStrings("LocationsConfig context:5 tscontext:on tslocations:off layout:stacked\n", file.content); } try std.testing.expectEqual(@as(usize, 1), reports); try dump.dumpState(p); const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); try std.testing.expectEqual(expected, restored.locations_config); } test "TreeContext tag style preserves message rows and its opt-out survives restore" { if (comptime !syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 16 }); defer p.deinit(); const pane = try p.setTestFile("pub const Box = struct {\n pub fn run() void {\n" ++ (" // body\n" ** 30) ++ " }\n};\n"); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/test.zig"); if (!panes.File.supportsContext(pane)) return error.SkipZigTest; p.update(.{ .resize = .{ .cols = 60, .rows = 16, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); pane.file.?.tree_context = true; pane.file.?.scroll = 8; pane.msg_len = 0; _ = try p.render(p.scratch.allocator()); try std.testing.expect(p.settings.tree_context_tag_style); const full_height = p.surface.body_layers[0].viewport.h; try std.testing.expect(p.surface.body_layers[0].rows > full_height); try std.testing.expectEqual(FontRole.tagline, p.surface.body_layers[0].cells[0].style.font_role); pane.msg[0] = '!'; pane.msg_len = 1; _ = try p.render(p.scratch.allocator()); // An overlay takes nothing: the message is a chip OVER the body's first // row, so the viewport keeps every row it had. 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.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); // Sized to its message, flush with the pane's right edge, and the text // right aligned a cell short of that edge. try std.testing.expectEqual(r.x + r.w, band.viewport.x + band.viewport.w); try std.testing.expect(band.viewport.w < r.w - config.GUTTER); try std.testing.expectEqualStrings("!", band.cells[p.tagCapacity(band.viewport.w) - 2].grapheme()); pane.msg_len = 0; p.leader_on = true; _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h); p.leader_on = false; try std.testing.expect(p.executeBuiltinLine(0, "TreeContextTagStyle")); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(@as(u16, 0), p.surface.body_layers[0].rows); try std.testing.expectEqual(@as(u16, 0), pane.body_rows); try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(pane, r)).style.font_role); try dump.dumpState(p); const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); try std.testing.expect(!restored.settings.tree_context_tag_style); } test "TreeContext command and custom tag survive a workspace round trip" { if (comptime !syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("pub fn main() void {}\n"); try std.testing.expect(!panes.File.supportsContext(pane)); try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); const path = try p.gpa.dupe(u8, "/test.zig"); p.gpa.free(pane.file.?.path); pane.file.?.path = path; if (!panes.File.supportsContext(pane)) return error.SkipZigTest; try std.testing.expectEqualStrings(tagline.context_pane_tail, tagline.curTail(pane)); try std.testing.expect(!pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext on")); try std.testing.expect(pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext off")); try std.testing.expect(!pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); try std.testing.expect(pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext invalid")); try std.testing.expect(pane.file.?.tree_context); if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = try p.gpa.dupe(u8, " Save Keep Del"); try dump.dumpState(p); const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const saved = restored.panes[0].?; try std.testing.expect(saved.file.?.tree_context); try std.testing.expectEqualStrings(" Save Keep Del", tagline.curTail(saved)); } test "Togglettymode builtin and keyboard chord share mode transitions including edited tags" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; try std.testing.expect(std.mem.indexOf(u8, tagline.curTail(pane), " Mode ") != null); try pane.appendTag(" Togglettymode"); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); // The tag has its own mode: the body keeps tty while the tag is typed into. tagline.enterTag(p, pane); try std.testing.expect(pane.focus == .tag); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); try std.testing.expect(pane.focus == .body); tagline.enterTag(p, pane); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(pane.focus == .body); p.acknowledgePanelPresentation(&.{}); const tag = try tagline.tagText(p, p.scratch.allocator(), pane); const x = p.rects[0].x + TAG_TEXT_INSET + @as(u16, @intCast(std.mem.indexOf(u8, tag, "Togglettymode").?)); const y = p.rects[0].y; p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = x, .row = y } }); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); const file = try p.setTestFile("unchanged\n"); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, file.body.mode); try std.testing.expectEqualStrings("unchanged\n", file.file.?.content); } test "Filter is ordered after Del and toggles only its terminal pane" { const testing = std.testing; const del = std.meta.stringToEnum(Builtin, "Del") orelse return error.MissingDelBuiltin; const filter = std.meta.stringToEnum(Builtin, "Filter") orelse return error.MissingFilterBuiltin; try testing.expectEqual(@intFromEnum(del) + 1, @intFromEnum(filter)); const p = try Pardes.init(testing.allocator, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const first = p.panes[0].?; const palette_before = first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; try testing.expect(first.tty_filter); try testing.expect(p.executeBuiltinLine(0, "Filter")); try testing.expect(!first.tty_filter); try testing.expect(palette_before.eql(first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); const second_id = p.freeSlot().?; const second = try p.newShell(second_id, ""); try testing.expect(second.tty_filter); try testing.expect(p.executeBuiltinLine(second_id, "Filter")); try testing.expect(!second.tty_filter); try testing.expect(!first.tty_filter); const doc_id = p.freeSlot().?; const doc = try panes.Image.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); try testing.expect(!doc.tty_filter); try testing.expect(p.executeBuiltinLine(doc_id, "Filter")); try testing.expect(!doc.tty_filter); try testing.expect(!first.tty_filter); try testing.expect(p.executeBuiltinLine(0, "Filter")); try testing.expect(first.tty_filter); try testing.expect(!second.tty_filter); } test "compact tag commands stay beside their own prefix across layout changes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // initial shell spawn/geometry // A longer sibling never introduces invisible padding into this tag. const below_id = p.freeSlot().?; const below = try p.newShell(below_id, ""); const f = layout.findPane(p, p.active).?; layout.insert(p, f.col, f.idx + 1, below_id); layout.splitBelow(p, p.active, below); p.setCwd(below_id, "/a/deep/dir/whose/name/eats/the/right/pad/the/end/of/its/own/tagline"); p.sync(); while (p.nextEffect()) |_| {} const above = p.panes[0].?; try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); const above_tag = try tagline.tagText(p, gpa, above); defer gpa.free(above_tag); const below_tag = try tagline.tagText(p, p.scratch.allocator(), below); try std.testing.expect(below_tag.len > above_tag.len); const tail = tagline.terminal_pane_tail; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expectEqual(above.cwdSlice().len + tail.len, above_tag.len); p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqualStrings(tail, tagline.curTail(below)); try below.appendTag(" lots and lots of typing out here"); try std.testing.expectEqual(above_tag.len, (try tagline.tagText(p, p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); } test "Joincol folds the active column into its right neighbor, keeping its panes" { 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 right = p.freeSlot().?; _ = try p.newShell(right, ""); try std.testing.expect(layout.splitColumn(p, 0, right, false)); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); layout.joinCol(p, 0); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } test "Tty spawns a raw shell in the caller's directory" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/newtty-dir"); try std.testing.expect(p.executeBuiltinLine(0, "Tty")); const free = p.active; try std.testing.expect(free != 0); const nt = p.panes[free] orelse return error.NoNewPane; try std.testing.expect(nt.isTerminal()); try std.testing.expect(nt.body.mode == .tty); var found = false; while (p.nextEffect()) |effect| switch (effect) { .spawn => |s| if (@as(usize, s.pane) == free) { try std.testing.expectEqualStrings("/tmp/newtty-dir", s.cwd.slice()); found = true; }, else => {}, }; try std.testing.expect(found); } test "a terminal respawned into a closed pane's slot hangs the old shell up first" { // Hosts keep a shell per slot: were the spawn first, the close would // end the new shell and leave the old one running. const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); try std.testing.expect(p.executeBuiltinLine(0, "Tty")); const slot = p.active; while (p.nextEffect()) |_| {} try p.removePane(slot, null); try std.testing.expect(p.executeBuiltinLine(0, "Tty")); try std.testing.expectEqual(slot, p.active); var closed = false; var spawned = false; while (p.nextEffect()) |effect| switch (effect) { .close_pty => |c| if (c.pane == slot) { try std.testing.expect(!spawned); closed = true; }, .spawn => |s| if (s.pane == slot) { spawned = true; }, else => {}, }; try std.testing.expect(closed and spawned); } test "Tty9p marks only the new Linux terminal for a mounted shell" { if (comptime !hosted or @import("builtin").os.tag != .linux) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); p.fs.socket_path = "/tmp/pardes-test.sock"; try std.testing.expect(p.executeBuiltinLine(0, "Tty9p")); try std.testing.expect(p.active != 0); try std.testing.expect(!p.panes[0].?.v9fs_on_spawn); const pane = p.panes[p.active].?; try std.testing.expect(pane.isTerminal() and pane.body.mode == .tty); try std.testing.expect(pane.v9fs_on_spawn); try std.testing.expect(!pane.greet); try std.testing.expect(!exec.takesCommandLine(p, p.active)); panes.terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); try std.testing.expect(exec.takesCommandLine(p, p.active)); panes.terminal.feedOutput(p, pane, "\x1b]133;C\x07"); try std.testing.expect(!exec.takesCommandLine(p, p.active)); } test "first document fallback splits like Tty from the originating pane" { for ([_]u16{ 100, 198 }) |width| for (0..3) |from| { for ([_]bool{ false, true }) |collapsed| { const p = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer p.deinit(); const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer tty.deinit(); p.settings.placement = .pardes; tty.settings.placement = .pardes; if (collapsed) { layout.toggleCollapse(p, from); layout.toggleCollapse(tty, from); } p.sync(); tty.sync(); const before = p.rects; const parent = layout.splitParent(p, from); const at = layout.findPane(p, parent).?; // The Look origin need not be the currently active pane. p.active = (from + 1) % 3; const free = p.freeSlot().?; _ = try p.openTutorView(free); exec.placeNew(p, from, from, free, .doc); _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(free, p.active); try std.testing.expectEqual(free, p.col_panes[at.col][at.idx + 1]); for (0..3) |id| { try std.testing.expect(p.panes[id] != null); if (id != parent) try std.testing.expectEqualDeep(before[id], p.rects[id]); } for (0..4) |id| try std.testing.expectEqualDeep(tty.rects[id], p.rects[id]); for (0..p.ncol) |col| { try std.testing.expectEqualSlices(usize, tty.col_panes[col][0..tty.col_n[col]], p.col_panes[col][0..p.col_n[col]]); } } }; } test "a frame no one watches is not drawn, and is drawn once someone does" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const Watch = struct { var presents: usize = 0; var seen = false; const vtable: Host.VTable = .{ .present = present, .watched = watched }; fn present(_: ?*anyopaque, _: *const Surface) void { presents += 1; } fn watched(_: ?*anyopaque) bool { return seen; } }; Watch.presents = 0; Watch.seen = false; p.needs_frame = true; try p.pump(.{ .vtable = &Watch.vtable }); try std.testing.expectEqual(@as(usize, 0), Watch.presents); try std.testing.expect(p.needs_frame); // still owed Watch.seen = true; try p.pump(.{ .vtable = &Watch.vtable }); try std.testing.expectEqual(@as(usize, 1), Watch.presents); } test "Lspwhy after a Hover asks to explain that hover, in its file at its place, run from any pane" { if (!lsp.supports.contains(.hover)) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("const a = 1;\nconst b = a;\n"); const f = &pane.file.?; p.gpa.free(f.path); f.path = try p.gpa.dupe(u8, "/tmp/x.zig"); while (p.nextEffect()) |_| {} pane.body.cur_row = 1; pane.body.cur_col = 10; p.lspRequest(0, .hover, ""); var hover_at: u32 = 0; while (p.nextEffect()) |e| if (e == .lsp) { hover_at = e.lsp.offset; }; pane.body.cur_row = 0; pane.body.cur_col = 0; p.lsp_wait = null; // Run from another file's pane: still the hover's file and place. const other = p.freeSlot().?; _ = try p.openTutorView(other); p.lspRequest(other, .explain, ""); var seen = false; while (p.nextEffect()) |e| if (e == .lsp) { try std.testing.expectEqual(lsp.Kind.explain, e.lsp.kind); try std.testing.expectEqualStrings("hover ", e.lsp.arg.slice()); try std.testing.expectEqual(hover_at, e.lsp.offset); try std.testing.expectEqual(@as(u8, 0), e.lsp.pane); seen = true; }; try std.testing.expect(seen); } test "the Tutor pane is named +Tutor in the session's directory, never a bare path at the root" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const session = "/project/src"; @memcpy(p.session_dir[0..session.len], session); p.session_dir_len = session.len; const tutor = try p.openTutorView(1); try std.testing.expectEqualStrings("/project/src/+Tutor", tutor.file.?.path); } test "first document keeps its originating shell and only opens a column when it fits" { for ([_]u16{ 199, 200 }) |width| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); defer p.deinit(); p.settings.placement = .pardes; const shell = p.panes[0].?; _ = try p.openTutorView(1); exec.placeNew(p, 0, 0, 1, .doc); p.sync(); try std.testing.expectEqual(shell, p.panes[0].?); try std.testing.expectEqual(@as(usize, 1), p.active); if (width < 200) { try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualSlices(usize, &.{ 0, 1 }, p.col_panes[0][0..p.col_n[0]]); try std.testing.expectEqual(p.rects[0].y + p.rects[0].h, p.rects[1].y); } else { try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_panes[0][0]); try std.testing.expectEqual(@as(usize, 0), p.col_panes[1][0]); } } } test "acme placement: an empty active column takes the new pane whole" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); defer p.deinit(); try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); p.newColumn(0); // its tag has the keyboard: it is the active column tagline.exitHeader(p); p.sync(); // A Look from the shell's text still lands in the active column, as // acme's makenewwindow puts it in activecol first (util.c:456). _ = try p.openTutorView(1); exec.placeNew(p, 0, 0, 1, .doc); try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]); try std.testing.expectEqual(@as(usize, 1), p.active); try std.testing.expectEqual(@as(?usize, 1), p.activeColumn()); // Never a new column. try std.testing.expectEqual(@as(usize, 2), p.ncol); } test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 }); defer p.deinit(); const top = p.active; _ = try p.setTestFile("one\ntwo\nthree\n"); p.sync(); p.setActiveColumn(0); // Most of the pane is blank: a document looked from it starts under its // three lines (makenewwindow, util.c:482-486). const second = p.freeSlot().?; _ = try p.openTutorView(second); exec.placeNew(p, top, top, second, .doc); p.sync(); const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3; try std.testing.expectEqual(text_end, p.rects[second].y); // Fill both with text: no blank space is big, so the biggest pane is // halved. const full = "x\n" ** 80; for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full)); p.sync(); const big = if (p.rects[second].h >= p.rects[top].h) second else top; const was = p.rects[big]; const third = p.freeSlot().?; _ = try p.openTutorView(third); exec.placeNew(p, second, second, third, .doc); p.sync(); try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]); try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y); try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h); } test "acme placement: New and 9P's new take the bottom half of the last pane" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); defer p.deinit(); p.newScratchBelow(p.active); const last = p.active; p.sync(); const was = p.rects[last]; p.newScratchFor(last, .fs); const made = p.active; p.sync(); try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx); try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h); try std.testing.expect(p.rects[made].y > was.y + BOX_H); } test "Placement pardes brings back the old rules, and says so in DumpConfig" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); defer p.deinit(); try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes")); try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement); const top = p.active; p.sync(); const was = p.rects[top]; p.newScratchBelow(top); p.sync(); // Directly under the asking pane, as New always put it. try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx); try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h); try std.testing.expect(p.executeBuiltinLine(p.active, "Placement")); try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); } test "an empty column shows its theme's empty_col under its tag: acme's white, else the border" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 20 }); defer p.deinit(); p.newColumn(0); tagline.exitHeader(p); inline for (.{ "acme", "dark" }) |name| { try std.testing.expect(p.executeBuiltinLine(0, "Theme " ++ name)); p.chrome_animation.displayed = colors.ChromeTheme.fromTheme(p.theme()); p.sync(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const s = try p.render(arena.allocator()); const cell = s.at(p.col_x[1] + 3, p.screen_h - 2); const want = p.chromeTheme().empty_col; try std.testing.expectEqual(want, cell.style.bg.rgb); if (std.mem.eql(u8, name, "acme")) try std.testing.expectEqual([3]u8{ 0xff, 0xff, 0xff }, want) else try std.testing.expectEqual(p.chromeTheme().border, want); } } test "BootShell keep leaves the untouched boot shell a dragged document joins; replace closes it" { inline for (.{ "keep", "replace" }) |word| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); const shell = p.active; try std.testing.expect(p.panes[shell].?.isTerminal()); try std.testing.expect(p.executeBuiltinLine(shell, "BootShell " ++ word)); _ = try p.openTutorView(1); layout.insert(p, 0, 1, 1); p.sync(); exec.evictLonePristineTty(p, 0, 1); p.sync(); const kept = std.mem.eql(u8, word, "keep"); try std.testing.expectEqual(kept, p.panes[shell] != null); try std.testing.expectEqual(@as(usize, if (kept) 2 else 1), p.col_n[0]); } } test "a pane its whole text fits in never scrolls to keep a margin" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 30 }); defer p.deinit(); const pane = try p.setTestFile("one\ntwo\nthree\n"); // A second pane under it, as acme's placement puts one: right under the // three lines, which leaves the file exactly three body rows. const below = p.freeSlot().?; _ = try p.openTutorView(below); exec.placeNew(p, 0, 0, below, .doc); p.active = 0; p.sync(); try std.testing.expectEqual(@as(u16, 3), p.rects[0].h - pane.tag_rows); for ("jj") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); p.sync(); try std.testing.expectEqual(@as(i32, 2), pane.body.cur_row); try std.testing.expectEqual(@as(i32, 0), pane.scroll()); } test "dragging a column's only pane to another leaves that column in place, empty" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); defer p.deinit(); p.newColumn(0); p.newScratchIn(0, p.activeColumn().?); const moved = p.active; p.sync(); const weights = p.col_weight; const tag_serial = p.col_serial[1]; layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2); p.sync(); // acme's coldragwin: colclose then coladd (cols.c:516-517); the column // left behind keeps its place, width and tag. try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); try std.testing.expectEqual(tag_serial, p.col_serial[1]); try std.testing.expectEqualDeep(weights, p.col_weight); try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); // ...and a pane dragged into the empty column takes it whole. layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2); p.sync(); try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); } test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.terminal_pane_tail, config.topbar_str }) |tag| { var words = std.mem.tokenizeScalar(u8, tag, ' '); while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); } try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", tagline.columnTag(p, 0)); } test "an unsaved file's tag is its name alone: the grip, not a mark, says it is unsaved" { 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 pane = try p.setTestFile("before\n"); const clean = try tagline.tagText(p, p.scratch.allocator(), pane); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); const dirty = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expectEqualStrings(clean, dirty); try std.testing.expect(std.mem.indexOf(u8, dirty, " *") == null); // The dirty file and index's flag still say it. try std.testing.expect(ctlfs.pane.dirtyOf(pane)); } test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.acknowledgeShell(0, "/bin/sh", false); // A terminal free at a prompt in the same directory is not borrowed. p.setCwd(0, "/tmp/pardes-image-dir"); const image_doc = try p.newDocPane(1); image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc)); const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; try std.testing.expect(dst != 0 and dst != 1); try std.testing.expectEqualStrings("echo image-fallback", p.panes[dst].?.command.?); var spawned = false; while (p.nextEffect()) |effect| switch (effect) { .write => return error.TypedIntoATerminal, .spawn => |sp| if (sp.pane == dst) { try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); spawned = true; }, else => {}, }; try std.testing.expect(spawned); } const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, const vtable: Host.VTable = .{ .tty_taken = answer }; fn install(f: *FakeTtyQuery, p: *Pardes) void { p.host = .{ .ctx = f, .vtable = &vtable }; } fn answer(ctx: ?*anyopaque, pane: u8) bool { const f: *FakeTtyQuery = @ptrCast(@alignCast(ctx.?)); f.asked += 1; return f.taken[pane]; } }; test "Exec in a terminal whose tty is taken runs as a command pane instead of typing at the program" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/pardes-taken"); // vim, a pager, an agent: the host answers that this pane's tty is no // longer the prompt pardes forked var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; const dst = exec.execute(p, 0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun; // somewhere ELSE -- and still without moving focus, which is execute's // contract and the whole difference between it and a look try std.testing.expect(dst != 0); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqualStrings("echo taken-fallback", p.panes[dst].?.command.?); var spawned = false; while (p.nextEffect()) |effect| switch (effect) { .write => return error.WroteACommandLineIntoATerminal, .spawn => |sp| if (sp.pane == dst) { // ...in the directory the command was about, the taken pane's own try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); spawned = true; }, else => {}, }; 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 }); defer p.deinit(); while (p.nextEffect()) |_| {} const Count = struct { var spawns: usize = 0; var command: [32]u8 = undefined; var core: *Pardes = undefined; const vtable: Host.VTable = .{ .spawn = spawn }; fn spawn(_: ?*anyopaque, pane: u8, _: []const u8) void { spawns += 1; const line = core.panes[pane].?.command.?; @memcpy(command[0..line.len], line); } }; Count.core = p; p.host = .{ .vtable = &Count.vtable }; // A command pane closed and its slot taken, in one step, before the // effects run: only the second's spawn is performed. const slot = p.freeSlot().?; _ = try p.newCommand(slot, "", "first"); try p.removePane(slot, null); try std.testing.expectEqual(slot, p.freeSlot().?); _ = try p.newCommand(slot, "", "second"); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expectEqual(@as(usize, 1), Count.spawns); try std.testing.expectEqualStrings("second", Count.command[0.."second".len]); } test "a REPL bound for a language takes the exec made on a body in it; tags, Exec by name and command words still run" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const file = try p.setTestFile("print(1)\nls\n"); gpa.free(file.file.?.path); file.file.?.path = try gpa.dupe(u8, "/x.py"); const Sent = struct { var to: [MAX_PANES][128]u8 = undefined; var len: [MAX_PANES]usize = @splat(0); var spawned: bool = false; fn drain(pp: *Pardes) void { len = @splat(0); spawned = false; while (pp.nextEffect()) |effect| switch (effect) { .write => |w| { @memcpy(to[w.pane][len[w.pane]..][0..w.bytes.slice().len], w.bytes.slice()); len[w.pane] += w.bytes.slice().len; }, .spawn => spawned = true, else => {}, }; } fn said(pane: usize) []const u8 { 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); try std.testing.expect(p.executeBuiltinLine(1, "Repl pithon")); 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")); // 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. 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)); // Into a REPL that takes a bracketed paste (3.13's), the Enter waits // for the REPL to answer the paste: read with it, it would be part of it. p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004h" } }); Sent.drain(p); _ = exec.executeFrom(p, 0, "print(1)", true); Sent.drain(p); try std.testing.expectEqualStrings("\x1b[200~print(1)\x1b[201~", Sent.said(1)); p.update(.{ .output = .{ .pane = 1, .bytes = "print(1)" } }); Sent.drain(p); // Python's echo only restarts a short wait: the Enter goes once the // echo of the whole paste has gone quiet. try std.testing.expectEqualStrings("", Sent.said(1)); p.now_ns = p.panes[1].?.repl_enter_due_ns; p.update(.tick); Sent.drain(p); try std.testing.expectEqualStrings("\r", Sent.said(1)); // A paste of more than one line gets the blank line a block gets. _ = exec.executeFrom(p, 0, "print(11)\nprint(22)", true); Sent.drain(p); try std.testing.expectEqualStrings("\x1b[200~print(11)\nprint(22)\x1b[201~", Sent.said(1)); p.now_ns = p.panes[1].?.repl_enter_due_ns; p.update(.tick); Sent.drain(p); try std.testing.expectEqualStrings("\r\r", Sent.said(1)); // Nothing answering, it goes when its wait is up. _ = exec.executeFrom(p, 0, "print(2)", true); p.update(.tick); // on the clock: not yet, however old core time is Sent.drain(p); try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~")); p.now_ns = p.panes[1].?.repl_enter_due_ns; // the clock reaches its wait p.update(.tick); Sent.drain(p); try std.testing.expectEqualStrings("\r", Sent.said(1)); p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004l" } }); Sent.len[1] = 0; // A block left open by its indented last line gets the blank line that // ends it: a second Enter. _ = exec.executeFrom(p, 0, "def f():\n return 1", true); Sent.drain(p); try std.testing.expectEqualStrings("def f():\r return 1\r\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. 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)); // Escapes: a tag's word, Exec by name, a command word. Each a command. _ = exec.execute(p, 0, "ls"); Sent.drain(p); try std.testing.expect(Sent.spawned and Sent.len[1] == 0); try std.testing.expect(p.executeBuiltinLine(0, "Exec ls")); Sent.drain(p); try std.testing.expect(Sent.spawned and Sent.len[1] == 0); _ = exec.executeFrom(p, 0, "@`ls`", true); Sent.drain(p); try std.testing.expect(Sent.spawned and Sent.len[1] == 0); // Two bound: it asks, one key answers, Esc sends nowhere. try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); Sent.drain(p); 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); // The question is in the log, and a script answers it on the ctl. { const th = @import("ninep/testing.zig"); _ = exec.executeFrom(p, 0, "print(5)", true); var want: [32]u8 = undefined; try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} repl a b\n", .{file.serial}))); // A choice not offered is refused, and the question stands. try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer z\n").errno()); try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt); const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n"); try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status); try std.testing.expectEqualStrings("print(5)\r", answered.pty()); try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} a\n", .{file.serial}))); } _ = 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].?)); // Two bound always ask, whichever has a program running: one left // (Ctrl-D: the shell has the tty) is still offered, and chosen, it is // sent nothing, nothing runs, and that is said. host.taken[1] = false; _ = exec.executeFrom(p, 0, "import os", true); try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt); p.update(.{ .key = .{ .cp = 'a' } }); 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[2] = false; _ = exec.executeFrom(p, 0, "import os", true); try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt); p.update(.{ .key = .{ .cp = Key.escape } }); 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 -")); try std.testing.expect(p.panes[1].?.repl == null); try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); 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 "a Tty started by a shell's path keeps the path in its Tty+ word" { 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 /bin/sh")); try std.testing.expect(p.active != 0); p.acknowledgeShell(p.active, "/bin/sh", false); try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+/bin/sh ")); } 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()) |_| {} // 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("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], "shell \"/nonexistent\" not found") != null); try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell")); try std.testing.expect(caller.shell == null); // A directory passes access(X_OK); it is no shell either. try std.testing.expect(p.executeBuiltinLine(0, "Tty /etc")); try std.testing.expect(p.active == before); try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "Tty: not a shell: /etc is a directory") != 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")); } test "a command pane shows how its command ended, and the next command there runs below it" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/pardes-cmd"); const doc = try p.newDocPane(1); doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-cmd/pic.ppm") }; 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")); // Running, a second command goes to a pane of its own. const other = exec.execute(p, 1, "ls") orelse return error.NoCommandPane; try std.testing.expect(other != dst); while (p.nextEffect()) |_| {} p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "compiled\r\n" } }); // 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")); // Kill leaves a finished one be. try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) return error.SignalledAFinishedCommand; // A program that crashed out of its full screen leaves the emulator // in its modes; the next command starts clear of them. p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "\x1b[?1049h\x1b[?2004h\x1b[?1000h" } }); try std.testing.expect(panes.terminal.bracketedPaste(pane)); // Done, the directory's next command runs there, below what it showed. try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test")); try std.testing.expect(!panes.terminal.bracketedPaste(pane)); var respawned = false; while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) { respawned = true; }; try std.testing.expect(respawned); const body = try panes.terminal.screenTextAlloc(pane, gpa); defer gpa.free(body); const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost; const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost; const next = std.mem.indexOf(u8, body, "% make test") orelse return error.NoSeparator; try std.testing.expect(said < ended and ended < next); try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make test) running")); // Kill stops a running one: its whole process group, SIGTERM. try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); var signalled = false; while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) { try std.testing.expectEqual(PtySignal.term, effect.signal_pty.sig); signalled = true; }; try std.testing.expect(signalled); // The log says what ran and how it ended. const log = try ctlfs.events.freezeLog(p); defer gpa.free(log.bytes); var want: [64]u8 = undefined; // 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 { var signals: usize = 0; const vtable: Host.VTable = .{ .pty_signal = signal }; fn signal(_: ?*anyopaque, _: u8, _: PtySignal) void { signals += 1; } }; const host_before = p.host; p.host = .{ .vtable = &Count.vtable }; try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); try p.removePane(dst, null); _ = try p.newCommand(dst, "", "other"); while (p.nextEffect()) |effect| if (effect == .signal_pty) p.perform(effect); try std.testing.expectEqual(@as(usize, 0), Count.signals); p.host = host_before; // A command line longer than a command pane takes is refused. const long = "x" ** (exec.command_max + 1); try std.testing.expectEqual(@as(?usize, null), exec.execute(p, 1, long)); } test "image dump restores source bytes renderer choices and exact custom tail" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try p.deinitPane(p.panes[0].?); p.panes[0] = null; const source = "\x00embedded image bytes\xff"; const raw = try p.image_gpa.dupe(u8, source); const pane = panes.Image.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { p.image_gpa.free(raw); return err; }; pane.image.?.glyph_art = true; pane.image.?.pmode = .terminal; pane.image.?.ascii = false; if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = try p.gpa.dupe(u8, " Keep Del"); try dump.dumpState(p); var first_dump = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); defer first_dump.deinit(); const first = first_dump.value; try std.testing.expect(first.panes[0].image.?.petscii); try std.testing.expectEqual(dump.ImagePalette.terminal, first.panes[0].image.?.palette); try std.testing.expect(!first.panes[0].image.?.ascii); try std.testing.expectEqualStrings(" Keep Del", first.panes[0].tag_tail.?); const restored = try dump.initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const restored_pane = restored.panes[0].?; try std.testing.expect(restored_pane.image.?.glyph_art); try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag.own != null); try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(restored_pane)); try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try dump.dumpState(restored); var parsed = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); defer parsed.deinit(); const redump = parsed.value; const encoded = redump.panes[0].image.?.bytes_b64; const decoded = try dump.decodeBytes(gpa, encoded); defer gpa.free(decoded); try std.testing.expectEqualSlices(u8, source, decoded); try std.testing.expect(redump.panes[0].image.?.petscii); try std.testing.expectEqual(dump.ImagePalette.terminal, redump.panes[0].image.?.palette); try std.testing.expect(!redump.panes[0].image.?.ascii); try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?); } test "a Look on a directory does not type ls into an occupied terminal" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} var realbuf: [4096]u8 = undefined; const tmp = look.resolve(null, "/tmp", "/", &realbuf).dir; p.setCwd(0, tmp); var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; look.lookAt(p, 0, tmp); var spawned = false; while (p.nextEffect()) |effect| switch (effect) { .write => |w| if (w.pane == 0) return error.TypedLsIntoTheProgramOnTheTty, .spawn => spawned = true, else => {}, }; try std.testing.expect(spawned); // ...and the same look reuses that very pane once its program is gone: the // core keeps no state of its own about it, so recovery needs nothing reset host.taken[0] = false; look.lookAt(p, 0, tmp); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, .spawn => return error.SpawnedDespiteAFreeTerminalOnTheDirectory, else => {}, }; try std.testing.expectEqualStrings("ls\r", sent[0..sent_len]); try std.testing.expectEqual(@as(usize, 0), p.active); } test "a terminal its program gave back takes command lines again" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.acknowledgeShell(0, "/bin/sh", false); p.setCwd(0, "/tmp/pardes-taken-flip"); var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; try std.testing.expect(exec.execute(p, 0, "echo while-taken") != 0); while (p.nextEffect()) |_| {} // the program exited, so the host's answer changes — and the core asks // again, because it never cached the old one host.taken[0] = false; try std.testing.expectEqual(@as(?usize, 0), exec.execute(p, 0, "echo after")); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, .spawn => return error.SpawnedDespiteAPromptOfItsOwn, else => {}, }; try std.testing.expectEqualStrings("echo after\r", sent[0..sent_len]); } test "the host is asked about a tty only where a command line is about to go" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); var host: FakeTtyQuery = .{}; host.install(p); p.setCwd(0, "/tmp/pardes-lazy-a"); p.setCwd(1, "/tmp/pardes-lazy-b"); p.setCwd(2, "/tmp/pardes-lazy-c"); _ = try p.render(frame.allocator()); p.update(.{ .key = .{ .cp = 'x' } }); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 4, .row = 4 } }); p.update(.{ .resize = .{ .cols = 90, .rows = 28 } }); while (p.nextEffect()) |_| {} _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), host.asked); // The pane an Exec is typed into is one question, asked once... _ = exec.execute(p, 0, "echo lazy"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); // ...and a taken one is asked once too: the line then runs as a command // pane, with no other terminal to ask about. host.taken[0] = true; host.asked = 0; _ = exec.execute(p, 0, "echo lazy-again"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); } test "New opens an empty scratch below the caller, inheriting its directory" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; // the right column; active starts in the left p.setCwd(source, "/tmp/pardes-scratch-dir"); const source_col = layout.findPane(p, source).?.col; try std.testing.expect(p.executeBuiltinLine(source, "New")); // no shell IO: the scratch is created in-core, focused, below the caller while (p.nextEffect()) |_| {} const id = p.active; try std.testing.expect(id != source); try std.testing.expectEqual(source_col, layout.findPane(p, id).?.col); const np = p.panes[id].?; try std.testing.expect(np.file.?.output != null); // an output buffer, empty try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len); // its directory is a LIVE link to the opener, not a snapshot taken at New try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np)); p.setCwd(source, "/tmp/pardes-moved"); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); try p.removePane(source, null); p.sync(); try std.testing.expect(np.cwd == .owned); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); p.sync(); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); } test "owned cwd preserves long paths aliases and failed updates" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; var path: [3072]u8 = @splat('d'); path[0] = '/'; for (1..12) |i| path[i * 256] = '/'; p.setCwd(0, &path); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); try std.testing.expect(pane.cwdSlice().ptr != &path); const original = pane.cwdSlice(); try pane.setOwnedCwd(original); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); // A terminal's directory may be as long as a path is (PATH_MAX): one // past the effect's buffer goes out of line (spawn_dirs); only one past // the host's path limit is too long. const free = p.freeSlot().?; _ = try p.newShell(free, path[0..]); try std.testing.expectEqualStrings(&path, p.spawn_dirs[free].?); try p.removePane(free, null); while (p.nextEffect()) |_| {} var too_long: [limits.host_path_cap + 1]u8 = @splat('d'); too_long[0] = '/'; try std.testing.expectError(error.PathTooLong, p.newShell(free, &too_long)); try std.testing.expect(p.panes[free] == null); const effects = p.effects_len; 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], "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], "out of memory") != null); allocator.fail_index = std.math.maxInt(usize); p.setCwd(0, "/replacement"); try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); const allocations = allocator.allocations; p.setCwd(0, "/replacement"); try std.testing.expectEqual(allocations, allocator.allocations); pane.clearCwd(); try std.testing.expect(pane.cwd == .none); try std.testing.expectEqualStrings("", pane.cwdSlice()); } test "owned cwd close copies are transactional across inherited chains" { for (0..2) |failed_copy| { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); p.presentation.enabled = false; const parent = try p.setTestFile("body\n"); p.gpa.free(parent.file.?.path); parent.file.?.path = try p.gpa.dupe(u8, "/work/project/source.zig"); p.newScratchBelow(0); const first_id = p.active; const first = p.panes[first_id].?; p.newScratchBelow(0); const second = p.panes[p.active].?; p.newScratchBelow(first_id); const grandchild = p.panes[p.active].?; p.sync(); while (p.nextEffect()) |_| {} p.parkPendingWrite(0, "queued"); const pending = p.pending_write[0].?.bytes; const columns = p.col_panes; const counts = p.col_n; const active = p.active; const live_bytes = allocator.allocated_bytes - allocator.freed_bytes; allocator.fail_index = allocator.alloc_index + failed_copy; try std.testing.expectError(error.OutOfMemory, p.removePane(0, null)); try std.testing.expectEqual(parent, p.panes[0].?); try std.testing.expectEqual(parent, first.cwd.inherited); try std.testing.expectEqual(parent, second.cwd.inherited); try std.testing.expectEqual(first, grandchild.cwd.inherited); try std.testing.expectEqualDeep(columns, p.col_panes); try std.testing.expectEqualDeep(counts, p.col_n); try std.testing.expectEqual(active, p.active); try std.testing.expectEqual(@as(usize, 0), p.effects_len); try std.testing.expectEqual(pending.ptr, p.pending_write[0].?.bytes.ptr); try std.testing.expectEqual(live_bytes, allocator.allocated_bytes - allocator.freed_bytes); allocator.fail_index = std.math.maxInt(usize); try p.removePane(0, null); try std.testing.expect(p.panes[0] == null); try std.testing.expect(first.cwd == .owned and second.cwd == .owned); try std.testing.expectEqual(first, grandchild.cwd.inherited); try std.testing.expect(first.cwdSlice().ptr != second.cwdSlice().ptr); try p.removePane(first_id, null); try std.testing.expect(grandchild.cwd == .owned); p.sync(); p.sync(); try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(second)); try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(grandchild)); } } test "owned cwd column close allocates every copy before removing any pane" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); p.presentation.enabled = false; p.setCwd(0, "/first"); p.setCwd(1, "/second"); const first_child = try p.newDocPane(3); const second_child = try p.newDocPane(4); first_child.cwd = .{ .inherited = p.panes[0].? }; second_child.cwd = .{ .inherited = p.panes[1].? }; while (p.nextEffect()) |_| {} const before = p.panes; const columns = p.col_panes; const counts = p.col_n; allocator.fail_index = allocator.alloc_index + 1; try std.testing.expectError(error.OutOfMemory, p.removeColumn(0)); for (before, p.panes) |old, current| try std.testing.expectEqual(old, current); try std.testing.expectEqualDeep(columns, p.col_panes); try std.testing.expectEqualDeep(counts, p.col_n); try std.testing.expectEqual(@as(usize, 0), p.effects_len); try std.testing.expect(first_child.cwd == .inherited and second_child.cwd == .inherited); allocator.fail_index = std.math.maxInt(usize); try p.removeColumn(0); try std.testing.expect(p.panes[0] == null and p.panes[1] == null); p.sync(); p.sync(); try std.testing.expectEqualStrings("/first", first_child.cwdSlice()); try std.testing.expectEqualStrings("/second", second_child.cwdSlice()); } test "owned cwd EOF failure retains the pane and Del retries its close" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); p.presentation.enabled = false; p.setCwd(0, "/exited"); const parent = p.panes[0].?; p.newScratchBelow(0); const child = p.panes[p.active].?; p.sync(); while (p.nextEffect()) |_| {} allocator.fail_index = allocator.alloc_index; p.update(.{ .eof = .{ .pane = 0 } }); try std.testing.expectEqual(parent, p.panes[0].?); try std.testing.expectEqual(parent, child.cwd.inherited); try std.testing.expectEqual(Text.Mode.normal, parent.body.mode); try std.testing.expect(std.mem.indexOf(u8, parent.msg[0..parent.msg_len], "Del retries close") != null); try std.testing.expectEqual(@as(usize, 0), p.effects_len); allocator.fail_index = std.math.maxInt(usize); try std.testing.expect(p.executeBuiltinLine(0, "Del")); try std.testing.expect(p.panes[0] == null); p.sync(); p.sync(); try std.testing.expectEqualStrings("/exited", child.cwdSlice()); } test "owned cwd restore either copies the directory or preserves the old core" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); p.setCwd(0, "/retained/terminal/directory"); try dump.dumpState(p); const original = p.panes[0].?; var completed = false; for (0..256) |failure| { allocator.has_induced_failure = false; allocator.fail_index = allocator.alloc_index + failure; const replacement = dump.restore(p, p.dump_out.?, "test") catch { try std.testing.expect(allocator.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); continue; }; defer replacement.deinit(); try std.testing.expectEqualStrings("/retained/terminal/directory", replacement.panes[0].?.cwdSlice()); try std.testing.expectEqual(original, p.panes[0].?); if (!allocator.has_induced_failure) { completed = true; break; } } allocator.fail_index = std.math.maxInt(usize); try std.testing.expect(completed); } test "owned cwd Save promotion releases the former directory" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); p.newScratchBelow(0); const id = p.active; const pane = p.panes[id].?; try pane.setOwnedCwd("/old/directory"); exec.saveTo(p, id, "saved.txt"); // Named once the write is done (a failed one changes nothing). try std.testing.expect(pane.file.?.output != null); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expect(pane.cwd == .none); try std.testing.expect(pane.file.?.output == null); try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); try std.testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); } test "Save on a scratch asks for a path in its inherited dir and makes it a file" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; p.setCwd(source, "/tmp/pardes-save-dir"); try std.testing.expect(p.executeBuiltinLine(source, "New")); const id = p.active; const np = p.panes[id].?; p.sync(); // the frame boundary that gives the new pane its geometry while (p.nextEffect()) |_| {} // Save on a scratch arms a PATH input, prefilled with the inherited dir try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(np.prompt == .save); try std.testing.expectEqualStrings(" Save /tmp/pardes-save-dir/", np.promptText().?); // typing the filename and submitting converts it into an ordinary file edit.insertKey(p, &np.input, .{ .cp = 'n', .text = "note.txt" }); exec.submitSave(p, id); // The write asked for, the pane is the file once it is done. var saved = false; while (p.nextEffect()) |effect| { if (effect == .save_text and @as(usize, effect.save_text.pane) == id) saved = true; p.perform(effect); } try std.testing.expect(saved); try std.testing.expect(np.file.?.output == null); try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path); try std.testing.expectEqual(np.file.?.revision, np.file.?.saved_revision); } test "Save on a terminal writes its plaintext scrollback and stays a terminal" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; p.setCwd(0, "/tmp/pardes-tty-save"); p.update(.{ .output = .{ .pane = 0, .bytes = "hello scrollback\r\n" } }); while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(0, "Save")); try std.testing.expect(pane.prompt == .save); edit.insertKey(p, &pane.input, .{ .cp = 'l', .text = "log.txt" }); exec.submitSave(p, 0); // the pane is untouched: still a terminal, no file behind it try std.testing.expect(pane.isTerminal()); try std.testing.expect(pane.file == null); // the host performs it, reading the scrollback off the pane as it goes var buf: [256]u8 = undefined; try std.testing.expectEqualStrings( "/tmp/pardes-tty-save/log.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); const written = p.fallback.get("/tmp/pardes-tty-save/log.txt") orelse return error.NothingWritten; try std.testing.expect(std.mem.indexOf(u8, written, "hello scrollback") != null); } test "Save on an output buffer writes its rows out and leaves the buffer alone" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; p.setCwd(source, "/tmp/pardes-output-save"); try std.testing.expect(p.executeBuiltinLine(source, "Help")); p.sync(); // the frame boundary that gives the new pane its geometry while (p.nextEffect()) |_| {} const help: panes.Output.Origin = .{ .cmd = .Help }; const id = blk: { for (p.panes, 0..) |slot, i| { const pane = slot orelse continue; const f = pane.file orelse continue; const o = f.output orelse continue; if (std.meta.eql(o.from, help)) break :blk i; } return error.MissingHelpBuffer; }; const out = p.panes[id].?; const path = try gpa.dupe(u8, out.file.?.path); defer gpa.free(path); const rows = try gpa.dupe(u8, out.file.?.content); defer gpa.free(rows); // Save leads its tagline now, and the path is REQUIRED: a bare Save asks try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); edit.insertKey(p, &out.input, .{ .cp = 'h', .text = "help.txt" }); exec.submitSave(p, id); // the buffer itself is untouched: same origin, same name, still refillable // and still walked by n/N, because a saved copy of a list is only a copy try std.testing.expect(out.file.?.output != null); try std.testing.expectEqual(help, out.file.?.output.?.from); try std.testing.expectEqualStrings(path, out.file.?.path); var buf: [256]u8 = undefined; try std.testing.expectEqualStrings( "/tmp/pardes-output-save/help.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expectEqualStrings( rows, p.fallback.get("/tmp/pardes-output-save/help.txt") orelse return error.NothingWritten, ); } test "Save takes the path as an argument, relative to the pane's own directory" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; p.setCwd(0, "/tmp/pardes-arg-save"); p.update(.{ .output = .{ .pane = 0, .bytes = "typed and gone\r\n" } }); while (p.nextEffect()) |_| {} var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save session.txt")); try std.testing.expect(pane.prompt != .save); try std.testing.expectEqualStrings( "/tmp/pardes-arg-save/session.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); const written = p.fallback.get("/tmp/pardes-arg-save/session.txt") orelse return error.NothingWritten; try std.testing.expect(std.mem.indexOf(u8, written, "typed and gone") != null); // `.` and `..` normalize with it, so this is that same file and not a // second one spelled differently try std.testing.expect(p.executeBuiltinLine(0, "Save ./sub/../session.txt")); try std.testing.expectEqualStrings( "/tmp/pardes-arg-save/session.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-elsewhere/abs.txt")); try std.testing.expectEqualStrings( "/tmp/pardes-elsewhere/abs.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expect(pane.isTerminal()); p.panes[0].?.clearCwd(); try std.testing.expect(p.executeBuiltinLine(0, "Save nowhere.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); } test "a save the host could not do leaves the pane dirty" { const gpa = std.testing.allocator; const Refusing = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void { const core: *Pardes = @ptrCast(@alignCast(ctx.?)); core.saveFailed(pane, "/x/y.txt", error.PermissionDenied); } }; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("before\n"); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.writeFile } }; try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); // ...and the same save against a host that CAN write does come clean, so // the guard above is not simply "never clean". p.host = .{}; try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); try std.testing.expectEqualStrings("after\n", p.fallback.get("/test.txt").?); } test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { 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 pane = try p.setTestFile("before\n"); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-copy/other.txt")); // a copy, never a rename: the pane keeps its file, and that file is still // unsaved, so the marker stays where it was try std.testing.expectEqualStrings("/test.txt", pane.file.?.path); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); try std.testing.expectEqualStrings( "/tmp/pardes-copy/other.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expectEqualStrings( "after\n", p.fallback.get("/tmp/pardes-copy/other.txt") orelse return error.NothingWritten, ); // its own path, spelled out, is the in-place write — nothing is asked of // the host but save_file, and the pane comes clean try std.testing.expect(p.executeBuiltinLine(0, "Save /test.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); } test "saves armed in one batch stay with their own panes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const tty_id = p.freeSlot() orelse return error.NoPaneSlots; const shell = try p.newShell(tty_id, "/tmp/pardes-batch"); p.setCwd(tty_id, "/tmp/pardes-batch"); p.update(.{ .output = .{ .pane = @intCast(tty_id), .bytes = "shell text\r\n" } }); const file = try p.setTestFile("file text\n"); const file_id = p.paneIdOf(file) orelse return error.MissingFilePane; try std.testing.expect(shell.isTerminal()); try std.testing.expect(tty_id != file_id); while (p.nextEffect()) |_| {} // both armed before the host drains anything — the path rides inside its // own effect, so neither can be written with the other pane's text try std.testing.expect(p.executeBuiltinLine(tty_id, "Save tty.txt")); try std.testing.expect(p.executeBuiltinLine(file_id, "Save /tmp/pardes-batch/file.txt")); while (p.nextEffect()) |effect| p.perform(effect); const tty_bytes = p.fallback.get("/tmp/pardes-batch/tty.txt") orelse return error.NothingWritten; try std.testing.expect(std.mem.indexOf(u8, tty_bytes, "shell text") != null); try std.testing.expectEqualStrings( "file text\n", p.fallback.get("/tmp/pardes-batch/file.txt") orelse return error.NothingWritten, ); } test "a save whose pane is gone before the drain writes nothing" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const doomed = p.freeSlot() orelse return error.NoPaneSlots; const shell = try p.newShell(doomed, "/tmp/pardes-gone"); p.update(.{ .output = .{ .pane = @intCast(doomed), .bytes = "doomed text\r\n" } }); while (p.nextEffect()) |_| {} try std.testing.expect(shell.isTerminal()); // Save, then close the pane, then let the host drain: the bytes are read // off the pane at drain time, so there are none to read... try std.testing.expect(p.executeBuiltinLine(doomed, "Save /tmp/pardes-gone/log.txt")); try std.testing.expect(p.executeBuiltinLine(doomed, "Del")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expect(p.fallback.get("/tmp/pardes-gone/log.txt") == null); // ...and a pane that took the freed slot in the meantime is NOT the pane // that asked, which is what the serial in the effect is for try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-gone/recycled.txt")); try std.testing.expect(p.executeBuiltinLine(0, "Del")); const reused = p.freeSlot() orelse return error.NoPaneSlots; _ = try p.newShell(reused, "/tmp/pardes-gone"); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expect(p.fallback.get("/tmp/pardes-gone/recycled.txt") == null); } test "Save reaches every tagline with text behind it and no other" { 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 out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out)); // A scratch is an output buffer that wears the FILE tails. out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } }; try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); } test "a save path that names no file is refused before anything is rewritten" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; p.setCwd(source, "/tmp/pardes-refuse"); try std.testing.expect(p.executeBuiltinLine(source, "New")); const id = p.active; const scratch = p.panes[id].?; p.sync(); while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(scratch.prompt == .save); exec.submitSave(p, id); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expect(scratch.file.?.output != null); try std.testing.expectEqualStrings("/tmp/pardes-refuse/+New", scratch.file.?.path); // ...and neither is a multi-line selection chorded onto the word try std.testing.expect(p.executeBuiltinLine(id, "Save one\ntwo")); try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expect(scratch.file.?.output != null); } test "a host with no methods at all is a complete in-process pardes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); defer p.deinit(); while (p.nextEffect()) |_| {} // A pump against the empty host renders a frame and blocks on nothing. try p.pump(.{}); try std.testing.expect(!p.quit); // the absent child is SILENT: the bytes are dropped, and nothing appears // on the pane's screen to suggest a program answered const before = p.panes[0].?.terminal.?.vt.screens.active.cursor.y; p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one p.registers.put(gpa, '+', "copied", 0, 1, true); p.perform(.set_clipboard); try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); // ...and a save with no filesystem lands in the virtual one const doc = try p.setTestFile("body\n"); var doc_id: u8 = 0; for (p.panes, 0..) |slot, i| if (slot == doc) { doc_id = @intCast(i); }; p.perform(.{ .save_file = .{ .pane = doc_id } }); try std.testing.expectEqualStrings("body\n", p.fallback.files.get(doc.file.?.path).?); // a language backend nobody supplied answers nothing rather than hanging p.perform(.{ .lsp = .{ .id = 7, .kind = .hover, .pane = doc_id, .offset = 0, .arg = .{} } }); p.perform(.quit); try std.testing.expect(p.quit); } test "a builtin that needs a pane reports capacity failure when every slot is full" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} for (0..MAX_PANES) |id| if (p.panes[id] == null) { _ = try p.newDocPane(id); }; 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: no space for a pane") != null); } test "Font keeps requested and acknowledged faces as separate plain state" { if (comptime !font_picker) return; // there is no Font builtin on tty or web const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const installed = fonts.list(arena.allocator(), null); if (installed.len == 0) return; // a machine with no monospace face at all var line: [512]u8 = undefined; const cmd = try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name}); try std.testing.expect(p.executeBuiltinLine(p.active, cmd)); try std.testing.expect(p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get()); try std.testing.expectEqualStrings(installed[0].path, p.settings.font.requested_path.get()); try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); try std.testing.expectEqualStrings("", p.settings.font.effective_name.get()); // Boot/zoom/display-scale reports describe the face already being drawn; // they must neither answer nor reject the new request waiting beside it. try std.testing.expect(p.observeFont("Embedded Mono", 1200, .pixels)); try std.testing.expectEqualStrings("Embedded Mono", p.settings.font.effective_name.get()); try std.testing.expect(p.settings.font.pending); try std.testing.expect(!p.acknowledgeFont("Stray Ack", 1250, .pixels)); p.rejectFont(); try std.testing.expect(p.settings.font.pending); const request = p.takeFontRequest() orelse return error.MissingFontRequest; 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: animation.Track = .{ .serial = p.panes[p.active].?.serial, .pane = @intCast(p.active), .effect = .slide, .from = .{ .w = 20, .h = 10 }, .to = .{ .x = 10, .w = 20, .h = 10 }, }; p.presentation.tracks[p.active] = fake_track; p.presentation.shown_tracks[p.active] = fake_track; p.presentation.acknowledged = true; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(!p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); try std.testing.expectEqual(@as(u16, 1375), p.settings.font.effective_size_hundredths); try std.testing.expectEqual(config.Runtime.FontSizeUnit.pixels, p.settings.font.effective_size_unit); try std.testing.expect(p.presentation.tracks[p.active] == null); try std.testing.expect(p.presentation.pending); try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels)); p.acknowledgePanelPresentation(&.{}); try std.testing.expect(p.executeBuiltinLine(p.active, cmd)); _ = p.takeFontRequest() orelse return error.MissingRepeatedFontRequest; p.presentation.tracks[p.active] = fake_track; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(p.presentation.tracks[p.active] != null); try std.testing.expect(!p.presentation.pending); // A name nothing answers to changes neither request nor effective state. try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face")); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get()); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); } test "Font size suffix reaches native request and invalid input leaves it intact" { if (comptime !font_picker) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const installed = fonts.list(arena.allocator(), null); if (installed.len == 0) return; var line: [512]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:18.5", .{installed[0].name}))); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); try std.testing.expectEqualStrings(installed[0].path, p.takeFontRequest().?); try std.testing.expect(p.acknowledgeFont(installed[0].name, 1850, .pixels)); try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:nan", .{installed[0].name}))); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); try std.testing.expect(p.takeFontRequest() == null); try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face:20")); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); try std.testing.expect(p.takeFontRequest() == null); try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name}))); try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.effective_size_hundredths); } test "Pet command is SDL-only optional and validates its argument" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); try std.testing.expectEqual(.off, p.settings.pet); if (comptime platform != .gui) { try std.testing.expect(std.meta.stringToEnum(builtins.registry.Builtin(), "Pet") == null); return; } try std.testing.expect(p.executeBuiltinLine(p.active, "Pet cat")); try std.testing.expectEqual(.cat, p.settings.pet); try std.testing.expect(p.executeBuiltinLine(p.active, "Pet dog")); try std.testing.expectEqual(.cat, p.settings.pet); try std.testing.expect(p.executeBuiltinLine(p.active, "Pet frog")); try std.testing.expectEqual(.frog, p.settings.pet); try std.testing.expect(p.executeBuiltinLine(p.active, "Pet off")); try std.testing.expectEqual(.off, p.settings.pet); } test "startup config overrides restored styling before initFromDump returns" { const source = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme dark\n" }); defer source.deinit(); try std.testing.expectEqualStrings("dark", source.theme().name); try dump.dumpState(source); const restored = try dump.initFromDump( std.testing.allocator, .{ .startup_config = "Theme acme\n" }, source.dump_out.?, ); defer restored.deinit(); try std.testing.expectEqualStrings("acme", restored.theme().name); try std.testing.expect(!source.animationActive()); try std.testing.expect(!restored.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(source.theme()), source.chromeTheme().*); try std.testing.expectEqual(ChromeTheme.fromTheme(restored.theme()), restored.chromeTheme().*); } test "restored sessions animate layout changes after bootstrap" { if (platform == .web) return; const source = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 16 }); defer source.deinit(); try dump.dumpState(source); const restored = try dump.initFromDump( std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 16, .startup_config = "PanelSlide\n" }, source.dump_out.?, ); defer restored.deinit(); 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); defer frame.deinit(); const initial = try restored.render(frame.allocator()); restored.acknowledgePanelPresentation(initial.panelTracks()); _ = try restored.newShell(1, ""); try std.testing.expect(layout.splitColumn(restored, 0, 1, false)); restored.sync(); try std.testing.expect(restored.presentation.tracks[0] != null); try std.testing.expect(restored.presentation.tracks[1] != null); try std.testing.expect(restored.animationActive()); } test "restored terminals keep monotonic nonzero pane identities" { const gpa = std.testing.allocator; const source = try Pardes.init(gpa, .{ .shells = 3 }); defer source.deinit(); // Filter is a fresh presentation default, not emulator/dump state. Even a // pane toggled off in the source session comes back with the default on. source.panes[1].?.tty_filter = false; try dump.dumpState(source); const restored = try dump.initFromDump(gpa, .{}, source.dump_out.?); defer restored.deinit(); var ids: [MAX_PANES]usize = undefined; var serials: [MAX_PANES]u32 = undefined; var n: usize = 0; for (restored.panes, 0..) |slot, id| { const pane = slot orelse continue; if (!pane.isTerminal()) continue; try std.testing.expect(pane.tty_filter); try std.testing.expect(pane.serial != 0); for (serials[0..n]) |seen| try std.testing.expect(pane.serial != seen); ids[n] = id; serials[n] = pane.serial; n += 1; } try std.testing.expectEqual(@as(usize, 3), n); for (ids[0..n], serials[0..n]) |id, serial| try std.testing.expectEqual(id, restored.paneBySerial(serial).?); const before = restored.next_serial; const free = restored.freeSlot() orelse return error.NoFreePane; const fresh = try restored.newShell(free, ""); try std.testing.expect(fresh.tty_filter); try std.testing.expectEqual(before + 1, fresh.serial); try std.testing.expectEqual(fresh.serial, restored.next_serial); } fn chordName(comptime c: config.Chord) []const u8 { comptime { const mods = (if (c.ctrl) "C-" else "") ++ (if (c.alt) "A-" else "") ++ (if (c.shift) "S-" else ""); for (@typeInfo(Key).@"struct".decls) |d| { if (@TypeOf(@field(Key, d.name)) != u21) continue; if (@field(Key, d.name) == c.cp) return mods ++ d.name; } if (c.cp < 0x20 or c.cp > 0x7e) @compileError("chord has no name for the index"); return mods ++ &[_]u8{@as(u8, @intCast(c.cp))}; } } fn shortcutCount(comptime b: Builtin) comptime_int { var count = 0; for (config.window_keys) |wk| if (wk.cmd == b) { count += 2; }; for (config.jump_keys) |jk| if (jk.cmd == b) { count += 1; }; if (config.look_cmd == b) count += config.look_key.len + 1; if (config.exec_cmd == b) count += config.exec_key.len + 1; var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| if (std.mem.eql(u8, w, @tagName(b))) { count += 1; }; return count; } fn shortcuts(comptime b: Builtin) []const u8 { comptime { @setEvalBranchQuota(20000); var parts: [shortcutCount(b)][]const u8 = undefined; var part: usize = 0; for (config.window_keys) |wk| { if (wk.cmd != b) continue; // the prefix has one spelling and this is it const pfx = chordName(config.window_prefix[0]) ++ " "; parts[part] = pfx ++ chordName(wk.letter); parts[part + 1] = pfx ++ chordName(wk.arrow); part += 2; } for (config.jump_keys) |jk| { if (jk.cmd == b) { parts[part] = chordName(jk.chord); part += 1; } } if (config.look_cmd == b) { for (config.look_key) |k| { parts[part] = chordName(k); part += 1; } parts[part] = @tagName(config.look_button) ++ "-click"; part += 1; } if (config.exec_cmd == b) { for (config.exec_key) |k| { parts[part] = chordName(k); part += 1; } parts[part] = @tagName(config.exec_button) ++ "-click"; part += 1; } var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| { if (std.mem.eql(u8, w, @tagName(b))) { parts[part] = "topbar"; part += 1; } } var len: usize = if (parts.len == 0) 0 else 2 * (parts.len - 1); for (parts) |text| len += text.len; var out: [len]u8 = undefined; var at: usize = 0; for (parts, 0..) |text, i| { if (i > 0) { @memcpy(out[at..][0..2], ", "); at += 2; } @memcpy(out[at..][0..text.len], text); at += text.len; } return &out; } } fn leaderKeys(comptime path: []const u8) []const u8 { comptime { var out: [4 + 2 * path.len - 1]u8 = undefined; @memcpy(out[0..4], "SPC "); var at: usize = 4; for (path, 0..) |c, i| { if (i > 0) { out[at] = ' '; at += 1; } out[at] = c; at += 1; } return &out; } } const Row = struct { path: ?[]const u8, cmd: Builtin, line: []const u8 }; pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { // one pass per builtin per config table, and the insertion sort below is // O(n²) comptime branches on top of it @setEvalBranchQuota(200000); var rows: [std.enums.values(Builtin).len]Row = undefined; // the widest spaced-out path ("l D" = 3) and the widest name, so the three // columns line up var keyw: usize = 0; var namew: usize = 0; for (std.enums.values(Builtin)) |b| { if (config.leader_path.get(b)) |p| keyw = @max(keyw, 2 * p.len - 1); namew = @max(namew, @tagName(b).len); } for (std.enums.values(Builtin), 0..) |b, i| { const keys = if (config.leader_path.get(b)) |path| leaderKeys(path) else ""; const rest = shortcuts(b); const named = @tagName(b) ++ (if (rest.len == 0) "" else (" " ** (namew - @tagName(b).len)) ++ " " ++ rest); rows[i] = .{ .path = config.leader_path.get(b), .cmd = b, .line = keys ++ (" " ** (4 + keyw - keys.len)) ++ " " ++ named, }; } const last = "\x7f"; for (1..rows.len) |i| { var j = i; while (j > 0 and std.mem.lessThan(u8, rows[j].path orelse last, rows[j - 1].path orelse last)) : (j -= 1) { const t = rows[j]; rows[j] = rows[j - 1]; rows[j - 1] = t; } } break :blk rows; }; comptime { @setEvalBranchQuota(20000); // one branch per string byte, behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); words: while (it.next()) |w| { for (@typeInfo(Builtin).@"enum".fields) |f| if (std.mem.eql(u8, f.name, w)) continue :words; @compileError("topbar word is not a builtin: " ++ w); } } const WordBounds = struct { lo: usize, hi: usize }; pub fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; var hi: usize = col; while (hi < str.len and str[hi] != ' ') hi += 1; return .{ .lo = lo, .hi = hi }; } /// the whitespace-delimited word covering `col` in `str` (topbar dispatch) pub fn wordAtCol(str: []const u8, col: usize) []const u8 { const bounds = wordBoundsAtCol(str, col) orelse return ""; return str[bounds.lo..bounds.hi]; } /// `a` IS `b` or lives under it — how Grep drops a pane's directory when /// another pane's directory already covers it pub fn pathUnder(a: []const u8, b: []const u8) bool { const root = std.mem.trimEnd(u8, b, "/"); if (!std.mem.startsWith(u8, a, root)) return false; return a.len == root.len or a[root.len] == '/'; } const tutor_text = @embedFile("tutor.txt"); // ---- theme ---- const boot_buffer = \\x selects a line, Tab runs it. 0x optional. \\ \\-- flash: this image's rodata, then its code \\Hexdump 40000020 40 \\Hexdump 40050000 40 \\-- L2MEM: firmware data, then the heap \\Hexdump 4ff00000 40 \\Hexdump 4ff40000 40 \\-- the mask ROM \\Hexdump 4fc00000 20 \\-- UART0, the console you are reading on \\Peek 500ca000 4 \\-- LP_STORE0: write a word, read it back \\Poke 5011002c deadbeef \\Peek 5011002c \\-- RNG_DATA: not memory. Run it twice. \\Peek 501101a4 \\Peek 501101a4 \\-- the pins: Gpio draws JP1, Gpio 33 flips \\Gpio \\Gpio 33 ; test "every line of the board's boot buffer renders whole" { const cols: usize = @import("pardes_config").esp32p4_cols; var it = std.mem.splitScalar(u8, boot_buffer, '\n'); while (it.next()) |line| { std.testing.expect(line.len + 8 <= cols) catch |err| { std.debug.print("boot buffer line is {d} of {d} usable: \"{s}\"\n", .{ line.len, cols - 8, line }); return err; }; } // and the tour still visits what it says it visits try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "Hexdump 40000020") != null); try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "Poke 5011002c deadbeef") != null); try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "\nGpio\n") != null); } // ---- the boundary types ---- test "Unicode display cells map back to body and tag byte cursors" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("a界e\u{301}z\n"); const f = &pane.file.?; p.gpa.free(f.path); f.path = try p.gpa.dupe(u8, "界e\u{301}.txt"); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; const text_x = rect.x + config.GUTTER + config.PREFIX_W; const body_y = rect.y + BOX_H; // The second screen cell is the trailing half of the wide CJK grapheme. // Both halves map to its one byte boundary. p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = text_x + 2, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 2, .row = body_y } }); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); // A click in the wide path glyph drafts a name with its caret at the // grapheme start; arrow motion then advances by the full UTF-8 cluster. tagline.draftName(p, pane, 1); try std.testing.expectEqual(@as(i32, 0), pane.input.cur_col); edit.insertKey(p, &pane.input, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 3), pane.input.cur_col); edit.insertKey(p, &pane.input, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 6), pane.input.cur_col); pane.closePrompt(); const before = try gpa.dupe(u8, tagline.curTail(pane)); defer gpa.free(before); tagline.enterTag(p, pane); const insertion = pane.tag.cur_col; edit.insertKey(p, &pane.tag, .{ .cp = 'λ', .text = "λ" }); try std.testing.expectEqual(insertion + 2, pane.tag.cur_col); edit.insertKey(p, &pane.tag, .{ .cp = Key.backspace }); try std.testing.expectEqual(insertion, pane.tag.cur_col); try std.testing.expectEqualStrings(before, tagline.curTail(pane)); } test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); // Bold is an explicit preference; use it to expose the glyph boundaries. try std.testing.expect(p.executeBuiltinLine(0, "SyntaxBold")); const pane = try p.setTestFile("\treturn x\n"); const f = &pane.file.?; f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); @memset(f.highlights, @intFromEnum(syntax.Syn.none)); @memset(f.highlights[1..7], @intFromEnum(syntax.Syn.keyword)); f.syntax_dirty = false; pane.body.cur_row = 0; pane.body.cur_col = 1; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const surface = try p.render(frame.allocator()); const rect = p.rects[0]; const text_x = rect.x + config.GUTTER + config.PREFIX_W; const body_y = rect.y + BOX_H; for (0..config.tab_width) |col| try std.testing.expect(!surface.at(text_x + @as(u16, @intCast(col)), body_y).style.bold); for (0.."return".len) |col| try std.testing.expect(surface.at(text_x + config.tab_width + @as(u16, @intCast(col)), body_y).style.bold); try std.testing.expectEqual(text_x + config.tab_width, surface.cursor.?.x); const click_x = text_x + config.tab_width + 2; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, 3), pane.body.cur_col); const line = modal.lineSlice(f.content, 0); const virtual: u16 = 3; const blank_x = text_x + @as(u16, @intCast(panes.File.displayWidth(line))) + virtual; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = blank_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = blank_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, @intCast(line.len)), pane.body.cur_col); _ = frame.reset(.retain_capacity); const virtual_surface = try p.render(frame.allocator()); try std.testing.expectEqual(blank_x - virtual, virtual_surface.cursor.?.x); const local_blank: i32 = @intCast(blank_x - rect.x - config.GUTTER); try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null); // The immediately-adjacent EOL cell and an in-line separator are blank // too. Pointer expansion must not lean left into the preceding word. const adjacent: i32 = @intCast(config.PREFIX_W + panes.File.displayWidth(line)); try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null); const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len); try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); } test "the workspace tag's Restore word names the last dump, the tag edited around it or not, across a Restore too" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); p.setLastDump("/tmp/a.zon"); // The tag edited: its own text holds the Restore word as it was then. p.global_tag.own = try p.gpa.dupe(u8, try std.fmt.allocPrint(p.scratch.allocator(), "{s} Mine", .{tagline.headerDefault(p, .workspace)})); p.setLastDump("/tmp/b.zon"); try std.testing.expect(std.mem.indexOf(u8, p.global_tag.own.?, "Restore /tmp/b.zon Mine") != null); try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?, "/tmp/c.zon"); defer restored.deinit(); try std.testing.expect(std.mem.indexOf(u8, restored.global_tag.own.?, "Restore /tmp/c.zon Mine") != null); try std.testing.expectEqualStrings("/tmp/c.zon", restored.last_dump.?); } test "editable workspace and column tags are typed into and persist" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); p.acknowledgePanelPresentation(&.{}); try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); // a sweep over a header is its selection, the caret where it ended p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 0, .row = 0 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 6, .row = 0 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 6, .row = 0 } }); try std.testing.expect(p.global_tag.vsel.active and p.global_tag.vsel.col == 0 and p.global_tag.cur_col == 6); p.update(.{ .key = .{ .cp = 'H', .text = " Help" } }); try std.testing.expect(std.mem.startsWith(u8, p.global_tag.own.?, "Newcol Help Joincol")); p.update(.{ .key = .{ .cp = Key.escape } }); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "éNew")); p.update(.{ .key = .{ .cp = Key.backspace } }); try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "New")); p.update(.{ .paste = "Grep " }); try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?, "test"); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?); try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0)); p.gpa.free(p.col_tags[0].own.?); p.col_tags[0].own = try p.gpa.dupe(u8, ""); try dump.dumpState(p); const empty = try dump.restore(p, p.dump_out.?, "test"); defer empty.deinit(); try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0)); } test "column tags follow their column through split and close" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("left\n"); p.col_tags[0].own = try p.gpa.dupe(u8, "Left Find"); _ = exec.execute(p, 0, "Newcol"); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep"); p.active = p.col_panes[0][0]; layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } test "column tags render edit hover and scroll from the column edge" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); p.newColumn(0); p.newScratchIn(0, p.activeColumn().?); p.sync(); p.acknowledgePanelPresentation(&.{}); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); for (0..p.ncol) |column| { const x = p.col_x[column] + TAG_TEXT_INSET; const width = p.col_w[column] - TAG_TEXT_INSET; const initial = try p.render(frame.allocator()); for ("New", 0..) |byte, offset| try std.testing.expectEqual(byte, initial.at(x + @as(u16, @intCast(offset)), TOPBAR_H).text[0]); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x, .row = TOPBAR_H } }); const hovered = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, hovered.at(x, TOPBAR_H).style.bg); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = TOPBAR_H } }); try std.testing.expectEqual(column, p.header_column.?); try std.testing.expectEqual(@as(i32, 0), tagline.focusedHeader(p).?.cur_col); const at_start = try p.render(frame.allocator()); try std.testing.expectEqual(x, at_start.cursor.?.x); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 3, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = x + 3, .row = TOPBAR_H } }); const selected = try p.render(frame.allocator()); for (0..3) |offset| try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, selected.at(x + @as(u16, @intCast(offset)), TOPBAR_H).style.bg); p.update(.{ .key = .{ .cp = Key.home } }); p.update(.{ .key = .{ .cp = 'H', .text = "Help " } }); try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, column), "Help New Tty")); const edited = try p.render(frame.allocator()); try std.testing.expectEqual(x + 5, edited.cursor.?.x); // Only the text viewport participates in caret reveal. const long: [80]u8 = @splat('a'); if (p.col_tags[column].own) |own| p.gpa.free(own); p.col_tags[column].own = try p.gpa.dupe(u8, long[0 .. width - 1]); p.update(.{ .key = .{ .cp = Key.end } }); const at_end = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), p.header_scroll); try std.testing.expectEqual(x + width - 1, at_end.cursor.?.x); p.update(.{ .key = .{ .cp = 'b', .text = "b" } }); const revealed = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 1), p.header_scroll); try std.testing.expectEqual(x + width - 1, revealed.cursor.?.x); p.update(.{ .key = .{ .cp = Key.home } }); const home = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), p.header_scroll); try std.testing.expectEqual(x, home.cursor.?.x); p.update(.{ .key = .{ .cp = Key.escape } }); p.update(.pointer_leave); _ = frame.reset(.retain_capacity); } try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); } test "column tag first-cell execution keeps New local to the clicked column" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); p.newColumn(0); p.newScratchIn(0, p.activeColumn().?); p.sync(); for (0..2) |column| { p.active = p.col_panes[1 - column][0]; p.acknowledgePanelPresentation(&.{}); const before = p.col_n; p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = p.col_x[column] + TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = p.col_x[column] + TAG_TEXT_INSET, .row = TOPBAR_H } }); try std.testing.expectEqual(before[column] + 1, p.col_n[column]); try std.testing.expectEqual(before[1 - column], p.col_n[1 - column]); try std.testing.expectEqual(column, layout.findPane(p, p.active).?.col); } try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); } test "header clicks beyond text append and long UTF-8 tags reveal their caret" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.col_tags[0].own = try p.gpa.dupe(u8, "New"); p.acknowledgePanelPresentation(&.{}); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'x', .text = " extended" } }); try std.testing.expectEqualStrings("New extended", tagline.columnTag(p, 0)); p.update(.{ .paste = " 界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" }); const frame = try p.render(p.scratch.allocator()); try std.testing.expect(p.header_scroll > 0); try std.testing.expect(frame.cursor != null); try std.testing.expectEqual(TOPBAR_H, frame.cursor.?.y); try std.testing.expect(frame.cursor.?.x < p.screen_w); p.update(.{ .key = .{ .cp = Key.home } }); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(@as(usize, 0), p.header_scroll); } test "a newline typed into a column tag is a space: the tag stays one row" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.col_tags[0].own = try p.gpa.dupe(u8, "New"); p.acknowledgePanelPresentation(&.{}); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = Key.enter } }); p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } }); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqualStrings("New Snarf", tagline.columnTag(p, 0)); try std.testing.expectEqual(COLBAR_H, p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); } test "tiny screens keep pane space before adding a column tag" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 2, .tty_only = true }); defer p.deinit(); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); _ = try p.render(p.scratch.allocator()); } test "a screen too short for the column row releases a column tag's focus without losing its commands" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("unchanged body\n"); p.col_tags[0].own = try p.gpa.dupe(u8, "New Help"); p.acknowledgePanelPresentation(&.{}); const shown = p.rects[0]; try std.testing.expectEqual(TOPBAR_H + COLBAR_H, shown.y); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); try std.testing.expect(p.header_focus and p.header_column != null); p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expect(!p.header_focus and p.header_column == null); p.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); try std.testing.expectEqual(shown, p.rects[0]); try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); } test "a header's execute key runs the word under its cursor and lets go of the keyboard first" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); tagline.enterHeader(p, null); p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); p.update(.{ .key = .{ .cp = Key.tab } }); try std.testing.expect(!p.header_focus); try std.testing.expect(p.dump_out != null); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); } test "s in a header selects in the header's own text" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("body col\n"); tagline.enterHeader(p, null); p.update(.{ .key = .{ .cp = 'x' } }); p.update(.{ .key = .{ .cp = 's' } }); try std.testing.expect(pane.prompt == .search and pane.prompt_for == .workspace); // the prompt takes the keys while the header keeps the keyboard p.update(.{ .key = .{ .cp = 'c', .text = "col" } }); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(p.header_focus and pane.prompt == .none); var ranges: [Text.max_selections]modal.Selection = undefined; const text = try edit.flatSurface(p, &p.global_tag); const got = p.global_tag.ranges(text, 0, &ranges); try std.testing.expectEqual(@as(usize, 2), got.n); // Newcol, Joincol 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" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); try std.testing.expectEqual(@as(usize, 1), p.ncol); tagline.enterHeader(p, null); p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expectEqual(@as(usize, 2), p.ncol); // and the workspace tag lets go of the keyboard: the new, empty // column's tag has it try std.testing.expect(p.header_focus and p.header_column.? == 1); } test "window keys walk from a pane up through the column and workspace tags and back" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.newColumn(0); p.newScratchIn(0, p.activeColumn().?); p.sync(); p.active = p.col_panes[0][0]; const window = struct { fn key(core: *Pardes, letter: u21) void { core.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); core.update(.{ .key = .{ .cp = letter } }); } }; // Up from the top pane: its column's tag, in normal mode; `:` there is // the way back to the body. window.key(p, 'k'); try std.testing.expect(p.header_focus and p.header_column.? == 0); try std.testing.expectEqual(Text.Mode.normal, p.col_tags[0].mode); p.update(.{ .key = .{ .cp = 'w' } }); try std.testing.expect(p.col_tags[0].cur_col > 0); const remembered = p.col_tags[0].cur_col; window.key(p, 'l'); try std.testing.expect(p.header_focus and p.header_column.? == 1); window.key(p, 'h'); try std.testing.expectEqual(remembered, p.col_tags[0].cur_col); window.key(p, 'k'); try std.testing.expect(p.header_focus and p.header_column == null); window.key(p, 'j'); try std.testing.expect(p.header_focus and p.header_column.? == 0); window.key(p, 'j'); try std.testing.expect(!p.header_focus); try std.testing.expectEqual(p.col_panes[0][0], p.active); window.key(p, 'k'); p.update(.{ .key = .{ .cp = ':' } }); try std.testing.expect(!p.header_focus and p.panes[p.active].?.focus == .body); try std.testing.expectEqualStrings("body\n", p.panes[0].?.file.?.content); } pub const Mouse = struct { /// The host has inverted a compact body layer. Physical col/row still /// route pane chrome; these coordinates address the pane's logical text. pub const BodyHit = struct { serial: u32, row: u16, col: u16, pixel_x: f32, pixel_y: f32, metrics: RowMetrics, }; // sel indexing relies on left/middle/right being 0/1/2 pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none, back, forward }; pub const Kind = enum { press, release, motion, drag }; button: Button, kind: Kind, col: u16, row: u16, ctrl: bool = false, shift: bool = false, /// Alt, which xterm's mouse reports call meta. alt: bool = false, body_hit: ?BodyHit = null, tag_hit: ?TagHit = null, /// A pixel frontend's press on the rule it draws at the top of this row, /// which is where two panes meet if they do: the one grab that resizes /// them there. A row of text never is one; a terminal, with no rule, /// resizes by the grip. rule_above: bool = false, }; pub const Key = struct { cp: u21, // codepoint or one of the specials below text: []const u8 = "", ctrl: bool = false, alt: bool = false, shift: bool = false, pub const enter: u21 = 0x0D; pub const escape: u21 = 0x1B; pub const tab: u21 = 0x09; pub const backspace: u21 = 0x7F; // private-use plane for non-text keys, matching no real codepoint pub const up: u21 = 0xF0001; pub const down: u21 = 0xF0002; pub const left: u21 = 0xF0003; pub const right: u21 = 0xF0004; pub const home: u21 = 0xF0005; pub const end: u21 = 0xF0006; pub const page_up: u21 = 0xF0007; pub const page_down: u21 = 0xF0008; pub const delete: u21 = 0xF0009; pub const insert: u21 = 0xF000A; pub const print_screen: u21 = 0xF000B; pub const scroll_lock: u21 = 0xF000C; pub const pause: u21 = 0xF000D; pub const menu: u21 = 0xF000E; /// F1 to F25, in order: Fn is `f1 + n - 1`. pub const f1: u21 = 0xF0011; pub const f25: u21 = f1 + 24; }; pub fn hit(key: Key, binding: []const config.Chord) bool { for (binding) |c| { if (key.cp != c.cp or key.ctrl != c.ctrl or key.alt != c.alt) continue; if (c.shift and !key.shift) continue; return true; } return false; } pub const sel_slot = @intFromEnum(config.select_button); /// Pixel dimensions are meaningful only to the native PDF placement path. /// Keep resize events layout-only when that path is not part of the build. pub const CellPixels = if (pdf_enabled) struct { w: u16 = 8, h: u16 = 16, } else struct {}; pub const Event = union(enum) { key: Key, mouse: Mouse, resize: struct { cols: u16, rows: u16, cell_pixels: CellPixels = .{}, row_metrics: ?RowMetrics = null, }, output: struct { pane: u8, bytes: []const u8 }, /// The pane's pty reached end of file: its output is over. An /// interactive terminal closes; a command pane waits for `exited`. eof: struct { pane: u8, status: ?u8 = null }, /// A command pane's child exited, with this status (null: unknown). It /// is the command's end, whatever still holds its pty (a job it left in /// the background prints on until end of file). exited: struct { pane: u8, status: ?u8 }, lsp_resp: struct { id: u32, rows: ?[]const u8 }, pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure = null, }, file_changed: struct { pane: u8, bytes: []const u8 }, paste: []const u8, command: []const u8, /// Native shells may preserve sub-cell wheel distance in physical pixels. /// TTY button events still enter through the ordinary mouse path. pdf_scroll: struct { pane: u8, delta_pixels: f32 }, pinch: f32, touch_scroll: f32, /// The host pointer left the drawable area. Unlike an out-of-range motion, /// this must not clamp onto and preview the final grid cell. pointer_leave, /// The window gained (true) or lost the host's keyboard focus. focus: bool, tick, }; pub const attach_name_max = 256; pub const AttachRequest = struct { pane: u8, name: []const u8 }; pub const PendingWrite = struct { bytes: []u8, off: usize = 0 }; /// `command` is Kill's: SIGTERM to the terminal's foreground job, and never /// to the shell itself when the shell is in the foreground. pub const PtySignal = enum(u8) { int, term, hup, quit, kill }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const WatchMode = enum { reconcile, baseline_disk }; pub const effect_path_cap = 256; pub const Effect = union(enum) { /// A child for the pane in slot `pane` -- the one whose serial is /// `serial`: a pane closed and its slot taken before the effect is /// performed is not the one it was for (perform). spawn: struct { pane: u8, serial: u32, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, /// For the pane whose serial is `serial`, as `spawn` is (perform). signal_pty: struct { pane: u8, serial: u32, sig: PtySignal }, /// The terminal pane in this slot is gone: the shell hangs up its pty /// and ends the shell, rather than leave it running with nobody to read. close_pty: struct { pane: u8 }, open_link: Buf(256), /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap), promote: bool = false }, write_dump, set_clipboard, read_clipboard, /// `arg` up to 128 bytes, or for `explain` the query it explains, /// ` `. lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(160) }, /// Snapshot the matching request with pipeRequest(id), then run it away /// from the UI/event loop and answer with pipe_resp. pipe: struct { id: u32 }, watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile }, theme_file: struct { generation: u32, on: bool }, /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, attach: struct { pane: u8, name: Buf(attach_name_max) }, detach: struct { pane: u8 }, quit, fn Buf(comptime n: usize) type { return struct { data: [n]u8 = undefined, len: u16 = 0, pub const cap = n; pub fn slice(b: *const @This()) []const u8 { return b.data[0..b.len]; } pub fn from(text: []const u8) @This() { var b: @This() = .{}; std.debug.assert(text.len <= n); @memcpy(b.data[0..text.len], text); b.len = @intCast(text.len); return b; } }; } }; test "a new column takes width only from the column that created it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); const left_before = p.rects[2]; const right_before = p.rects[1]; _ = try p.newShell(3, ""); try std.testing.expect(layout.splitColumn(p, 0, 3, false)); p.sync(); try std.testing.expectEqual(left_before.x, p.rects[2].x); try std.testing.expectEqual(left_before.w, p.rects[2].w); try std.testing.expectEqual(right_before.x, p.rects[1].x); try std.testing.expectEqual(right_before.w, p.rects[1].w); try std.testing.expectEqual(@as(u16, 16), p.rects[0].w); try std.testing.expectEqual(@as(u16, 15), p.rects[3].w); const narrow = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2 - 1, .rows = 10 }); defer narrow.deinit(); try std.testing.expect(!layout.splitColumn(narrow, 0, 1, false)); try std.testing.expectEqual(@as(usize, 1), narrow.ncol); const sequential = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2, .rows = 10 }); defer sequential.deinit(); _ = try sequential.newShell(1, ""); try std.testing.expect(layout.splitColumn(sequential, 0, 1, false)); _ = try sequential.newShell(2, ""); try std.testing.expect(!layout.splitColumn(sequential, 0, 2, false)); try std.testing.expectEqual(@as(usize, 2), sequential.ncol); const coarse = try Pardes.init(std.testing.allocator, .{ .cols = 22, .rows = 10 }); defer coarse.deinit(); coarse.col_weight[0] = 3; _ = try coarse.newShell(1, ""); try std.testing.expect(layout.splitColumn(coarse, 0, 1, false)); coarse.sync(); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[0]); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[1]); const extreme = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20 }); defer extreme.deinit(); _ = try extreme.newShell(1, ""); try std.testing.expect(layout.splitColumn(extreme, 0, 1, false)); _ = try extreme.newShell(2, ""); layout.insert(extreme, 0, 1, 2); extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double extreme.col_weight[1] = 1; layout.compute(extreme); const panes_before = extreme.col_panes; const counts_before = extreme.col_n; const weights_before = extreme.col_weight; const source_vweight = extreme.panes[0].?.vweight; const sibling_vweight = extreme.panes[2].?.vweight; try std.testing.expect(!layout.splitColumn(extreme, 0, 0, false)); try std.testing.expectEqual(panes_before, extreme.col_panes); try std.testing.expectEqual(counts_before, extreme.col_n); try std.testing.expectEqual(weights_before, extreme.col_weight); try std.testing.expectEqual(source_vweight, extreme.panes[0].?.vweight); try std.testing.expectEqual(sibling_vweight, extreme.panes[2].?.vweight); } test "Newcol refuses an unsplittable restored weight before spawning" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 200, .rows = 20 }); defer p.deinit(); for (1..4) |id| { _ = try p.newShell(id, ""); try std.testing.expect(layout.splitColumn(p, id - 1, id, false)); } const restored_cap = std.math.maxInt(u64) / 6; for (0..4) |column| p.col_weight[column] = restored_cap; p.col_weight[3] -= 1; for ([_]usize{ 3, 2, 1 }) |id| { const pane = p.panes[id].?; try p.deinitPane(pane); layout.removePane(p, id); layout.dropColumn(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} try std.testing.expect(p.col_weight[0] > std.math.maxInt(u64) / 2); try std.testing.expect(p.col_weight[0] % 2 == 1); try std.testing.expect(!layout.canSplitColumn(p, 0)); const panes_before = p.panes; const serial_before = p.next_serial; try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); try std.testing.expectEqual(panes_before, p.panes); try std.testing.expectEqual(serial_before, p.next_serial); try std.testing.expect(p.nextEffect() == null); } test "layout commits publish finite tracks only for changed panes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); defer p.deinit(); // The mechanism, at a fixed flavour, not the default\'s tuning. p.settings.motion = .crisp; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const untouched = p.rects[1]; p.settings.panel_transition = .slide; _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); // Pane 1 belongs to the unrelated right column. Its exact layout stayed // fixed, so it does not receive a presentation record either. 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(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()); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len); 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); p.settings.panel_transition = .zoom; p.update(.{ .resize = .{ .cols = 101, .rows = 17 } }); try std.testing.expect(!p.animationActive()); for (p.presentation.tracks) |track| try std.testing.expect(track == null); } test "screen resize keeps its previous storage until allocation succeeds" { var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); const p = try Pardes.init(failing.allocator(), .{ .cols = 80, .rows = 24, .tty_only = true }); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); const surface = try p.render(frame.allocator()); const cells = surface.cells; p.update(.{ .resize = .{ .cols = 81, .rows = 25 } }); failing.fail_index = failing.alloc_index; try std.testing.expectError(error.OutOfMemory, p.render(frame.allocator())); try std.testing.expectEqual(cells.ptr, surface.cells.ptr); try std.testing.expectEqual(cells.len, surface.cells.len); try std.testing.expectEqual(@as(u16, 80), surface.cols); try std.testing.expectEqual(@as(u16, 24), surface.rows); failing.fail_index = std.math.maxInt(usize); const resized = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 81), resized.cols); try std.testing.expectEqual(@as(u16, 25), resized.rows); try std.testing.expectEqual(@as(usize, 81 * 25), resized.cells.len); } test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); // The mechanism, at a fixed flavour, not the default\'s tuning. p.settings.motion = .crisp; 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()); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); p.acknowledgePanelPresentation(opening.panelTracks()); try p.removePane(1, null); p.sync(); try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); } // Once the pane itself has reached a canonical acknowledged frame, that // exact box is safe frozen source data for its ordinary closing slide. { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); // The mechanism, at a fixed flavour, not the default\'s tuning. p.settings.motion = .crisp; 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()); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); 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); p.acknowledgePanelPresentation(canonical.panelTracks()); const serial = p.panes[1].?.serial; try p.removePane(1, null); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.presentation.closing_len); try std.testing.expectEqual(serial, p.presentation.closing[0].serial); } } test "previous-grid animation refreshes its mask and snaps overlapping layout churn" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); 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()); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); const changed_index = for (opening.cell_diffs, 0..) |diff, index| { if (diff.changed()) break index; } else return error.MissingPanelDiff; p.acknowledgePanelPresentation(opening.panelTracks()); // Corrupt one cached classification to prove a later live frame derives it again // from the frozen old cells and freshly rendered new cells. p.presentation.diffs[changed_index] = .unchanged; _ = frame.reset(.retain_capacity); const refreshed = try p.render(frame.allocator()); try std.testing.expect(refreshed.cell_diffs[changed_index].changed()); p.acknowledgePanelPresentation(refreshed.panelTracks()); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); try std.testing.expect(p.presentation.diff_state == .none); try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); for (p.presentation.tracks) |track| try std.testing.expect(track == null); try std.testing.expect(p.presentation.pending); _ = frame.reset(.retain_capacity); const snapped = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), snapped.panelTracks().len); } test "canonical fallback and transition toggle retire unpresented tracks" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const animated = try p.render(frame.allocator()); try std.testing.expect(animated.panelTracks().len > 0); // A host which rendered the canonical fallback acknowledges exactly that // empty record set; producers cannot resume on its next frame. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(!p.animationActive()); try std.testing.expect(p.presentation.diff_state == .none); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); // Recreate a visible sample, then change the selected effect. Actual old // pixels remain on screen until the next submit, so pointer input waits. p.settings.panel_transition = .slide; p.col_weight[0] = column_weight_unit * 2; p.col_weight[1] = column_weight_unit; p.sync(); _ = frame.reset(.retain_capacity); const moving = try p.render(frame.allocator()); p.acknowledgePanelPresentation(moving.panelTracks()); try std.testing.expect(moving.panelTracks().len > 0); exec.applySettingBuiltin(p, config.Runtime.find("PanelZoom").?, null); 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); p.acknowledgePanelPresentation(&.{}); p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; p.sync(); _ = frame.reset(.retain_capacity); const before_resize = try p.render(frame.allocator()); p.acknowledgePanelPresentation(before_resize.panelTracks()); try std.testing.expect(before_resize.panelTracks().len > 0); const old_tagline_percent = p.settings.font.tagline_percent; var percent_buf: [3]u8 = undefined; const same_percent = try std.fmt.bufPrint(&percent_buf, "{d}", .{old_tagline_percent}); exec.applySettingBuiltin(p, config.Runtime.find("TaglineSize").?, same_percent); try std.testing.expect(p.animationActive()); const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73; const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent}); exec.applySettingBuiltin(p, config.Runtime.find("TaglineSize").?, changed_text); try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent); try std.testing.expect(p.presentation.pending); for (p.presentation.tracks) |track| try std.testing.expect(track == null); } test "back-to-back layout commits retarget from the last presented boxes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const initial = try p.render(frame.allocator()); p.acknowledgePanelPresentation(initial.panelTracks()); const shown_a = layout.panelBox(p.rects[0]); p.settings.panel_transition = .slide; p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; p.sync(); const target_b = p.presentation.tracks[0] orelse return error.MissingFirstRetarget; try std.testing.expect(target_b.from.eql(shown_a)); // No render or acknowledgement of B: the pixels are still A. C must not // jump to the logical-but-unseen B before beginning its own transition. p.col_weight[0] = column_weight_unit * 2; p.col_weight[1] = column_weight_unit; p.sync(); 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(animation.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); var submitted: ?animation.Track = null; for (latest.panelTracks()) |track| if (track.pane == 0) { submitted = track; break; }; const track = submitted orelse return error.MissingSubmittedRetarget; p.acknowledgePanelPresentation(latest.panelTracks()); try std.testing.expect(p.presentation.shown[0].?.box.eql(track.visualBox())); } test "an unpresented opening pane remains in the opening paint phase" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const initial = try p.render(frame.allocator()); p.acknowledgePanelPresentation(initial.panelTracks()); p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); 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(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(animation.Phase.opening, retargeted.phase); try std.testing.expect(retargeted.from.eql(first.from)); } test "core composes ASCII bytes while visual-only cells pass through" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); defer p.deinit(); var current: [4]Cell = @splat(.{ .default = false }); var previous = current; for (¤t, "Dwxq") |*cell, byte| cell.text[0] = byte; for (&previous, "Azxq") |*cell, byte| cell.text[0] = byte; current[2].style.bold = true; // visual-only: same glyph, new style current[3].text[0..2].* = .{ 0xc3, 0xa9 }; current[3].len = 2; // non-ASCII: canonical immediately const diffs = [_]PanelCellDiff{ .{ .ascii = .{ .from = 'A', .to = 'D' } }, .{ .ascii = .{ .from = 'z', .to = 'w' } }, .visual, .visual, }; var surface: Surface = .{ .cols = 4, .rows = 1, .cells = ¤t, .previous_cells = &previous, .cell_diffs = &diffs, }; surface.panel_tracks[0] = .{ .effect = .ascii, .frame = 2, .frame_count = 4, .from = .{ .w = 4, .h = 1 }, .to = .{ .w = 4, .h = 1 }, }; surface.npanel_tracks = 1; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); 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()); try std.testing.expectEqual(current[2], presented.at(2, 0).*); try std.testing.expectEqual(current[3], presented.at(3, 0).*); try std.testing.expectEqualStrings("D", surface.at(0, 0).grapheme()); } test "core composes character motion out of the new grid, not a fade" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); defer p.deinit(); var current: [8]Cell = @splat(.{ .default = false }); var previous = current; for (¤t, "abcdefgh") |*cell, byte| cell.text[0] = byte; for (&previous, "........") |*cell, byte| cell.text[0] = byte; // Motion effects carry every glyph in the pane, so a cell's semantic // classification is irrelevant to them; only the frozen grid matters. const diffs: [8]PanelCellDiff = @splat(.visual); var surface: Surface = .{ .cols = 8, .rows = 1, .cells = ¤t, .previous_cells = &previous, .cell_diffs = &diffs, }; surface.panel_tracks[0] = .{ .effect = .edges, .frame = 4, .to = .{ .w = 8, .h = 1 }, }; surface.npanel_tracks = 1; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); 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]; try std.testing.expectEqualStrings("cdefgh..", &seen); 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 = 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" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 979, .rows = 20 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const handle = p.col_x[0] + p.col_w[0] - 1; const untouched = p.col_weight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = TOPBAR_H + 1 } }); try std.testing.expectEqual(untouched, p.col_weight); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 417, .row = TOPBAR_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 417, .row = TOPBAR_H + 1 } }); p.sync(); try std.testing.expectEqual(@as(u16, 418), p.rects[0].w); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); const left_before = p.rects[0]; const middle_before = p.rects[1]; _ = try p.newShell(3, ""); try std.testing.expect(layout.splitColumn(p, 2, 3, false)); p.sync(); try std.testing.expectEqual(left_before, p.rects[0]); try std.testing.expectEqual(middle_before, p.rects[1]); } pub const Rect = layout.Rect; /// The session layouts `init` can put on screen. One name each, so choosing /// between them reads as a choice instead of a chain of ifs over four options. pub const Boot = enum { /// the document named on the command line, or the pane explaining why not document, /// a Look that found nothing: reported, not opened missing, /// `pardes -`: stdin's text in a +Pager pane, as `vim -` shows it stdin, /// the firmware's boot buffer, the only pane a board has embedded, /// the bare tty startup: one shell in raw tty mode, with an empty text /// pane under it so the editor half is there before you want it tty, /// that raw shell by itself, which is all a core booted for a test wants tty_shell, /// acme's boot: two columns, the left one split in two classic, /// one greeted shell shell, pub fn of(opts: Options) Boot { if (opts.boot) |named| return named; if (opts.file != null) return .document; if (opts.missing != null) return .missing; if (opts.stdin_text != null) return .stdin; if (comptime platform == .esp32p4) return .embedded; if (opts.tty_only) return .tty_shell; // ponytail: 1 and 3 are the only shell counts anyone asked for return if (opts.shells >= 3) .classic else .shell; } }; pub const Options = struct { tty_only: bool = false, /// The layout to boot, when a host means one outright rather than leaving /// it to be read off the options below. `--tty` names `.tty`; a core /// booted for a test names nothing and gets `.tty_shell`. boot: ?Boot = null, /// initial shell panes: 1 (default) or 3 for the classic two-column boot. /// A `file` outranks this — see there. shells: u8 = 1, file: ?[]const u8 = null, file_line: usize = 0, missing: ?struct { word: []const u8, dir: []const u8 } = null, /// `pardes -`: stdin, its escapes stripped, for a +Pager pane at boot. stdin_text: ?[]const u8 = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, nested: bool = false, /// The core of a `--detach` session (detached/server.zig): it has no /// terminal of its own, so Attach there is refused with why. detached_session: bool = false, startup_config: ?[]const u8 = null, ninep_name: []const u8 = "", ninep_tcp: ?[]const u8 = null, ninep_quic: ?[]const u8 = null, mounts: []const filesystem.Mount = &.{}, config_dir: ?[]const u8 = null, /// Where the list of files opened lately is kept across sessions /// (recent.zig), set by the host that has a state directory; none, it /// lives as long as the session. recent_path: ?[]const u8 = null, startup_config_path: ?[]const u8 = null, image_allocator: ?std.mem.Allocator = null, pdf_allocator: ?std.mem.Allocator = null, tree_sitter_allocator: ?std.mem.Allocator = null, /// Where each frame's Surface text is built. Hosts pass a purpose-built /// stack-fallback arena; null means the general allocator. frame_allocator: ?std.mem.Allocator = null, cols: u16 = 80, rows: u16 = 24, }; pub const Haptic = enum { none, exec, look }; /// Zero-sized off macOS, the way PdfSlot is off -Dmupdf: no other shell reads /// the field, so no other shell carries it. const HapticSlot = if (platform == .macos) Haptic else void; pub const RetiredPanes = struct { current: [2 * MAX_PANES]?*Pane = @splat(null), previous: [2 * MAX_PANES]?*Pane = @splat(null), fn retire(a: *RetiredPanes, pane: *Pane) void { for (&a.current) |*slot| if (slot.* == null) { slot.* = pane; return; }; // One update runs one builtin, and the widest drop is a whole column; // 2*MAX_PANES cannot fill in a single frame. unreachable; } }; pub const Pardes = struct { gpa: std.mem.Allocator, image_gpa: std.mem.Allocator, pdf_gpa: std.mem.Allocator, tree_sitter_gpa: std.mem.Allocator, opts: Options, screen_w: u16, screen_h: u16, cell_pixels: CellPixels = .{}, row_metrics: RowMetrics = .{}, resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), reserved_slots: [MAX_PANES]bool = @splat(false), /// Deferred teardown of dropped panes (see RetiredPanes). retired_panes: RetiredPanes = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, col_weight: [MAX_COLS]u64 = @splat(column_weight_unit), col_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), /// Each column's tag and the workspace's: Texts like a pane's tag, with /// no prefix, showing a default until someone edits them. col_tags: [MAX_COLS]Text = @splat(.{ .what = .column }), col_serial: [MAX_COLS]u32 = @splat(0), next_column_serial: u32 = 1, global_tag: Text = .{ .what = .workspace }, // derived each sync rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, presentation: Presentation = .{}, active: usize = 0, /// The files opened lately, closed ones too (recent.zig): `Recent`, /// /recent, and the jumplist's way back to a closed pane. recent: recent_files.List = .{}, /// The kept list was read (or there was none): only then is it written, /// so a session never writes over one it did not read. recent_loaded: bool = false, jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, look_src: [MAX_PANES]u32 = undefined, n_look_src: usize = 0, look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, /// The column whose tag the running command came from, while it runs: /// the column acme's commands take from their Text's col. exec_column: ?usize = null, /// Panes made this step that found no room in their column /// (exec.placeNew): closed at the step's end, once whatever made them /// is done with them. unplaced: std.StaticBitSet(MAX_PANES) = .initEmpty(), /// A look's word search is running (look.lookUnresolved): from a /// terminal its rows refill the directory's one +Search (Output). word_look: bool = false, /// The running word came from the workspace's or a column's tag, which /// have no directory of their own: it runs in the session's /// (`sessionDir`), as acme's row and column tags do. exec_header: bool = false, session_dir: [limits.host_path_cap]u8 = undefined, /// A spawn's directory too long for the effect's inline buffer, by pane /// slot: the effect then carries none, and perform gives the host this. spawn_dirs: [MAX_PANES]?[]u8 = @splat(null), session_dir_len: ?u16 = null, /// acme's activecol (dat.c:37), by column serial, 0 for none: the /// column last typed in (acme.c:487), clicked in with the select button /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new /// pane (util.c:467). Where the acme placement puts new panes /// (exec.placeNew). A Look click moves the keyboard but not this. active_column: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations.Config = .{}, locations_cache: locations.Cache = .{}, tty_filter_palette: panes.terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, /// bodyChrome's: the theme it was worked out for, and it. body_chrome: ?struct { theme: Theme, chrome: ChromeTheme } = null, theme_file_path: config.Runtime.Text(limits.host_path_cap) = .{}, /// The ThemeFile last loaded whole: what /ctl and DumpConfig name, and /// what a failed ThemeFile goes back to (colors.revertThemeFile). theme_file_loaded: config.Runtime.Text(limits.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: colors.ChromeAnimation = colors.ChromeAnimation.init(colors.initial_chrome), /// Each pane's lift off the page (Lift): 1 while it has the focus, on a /// spring. `lift_serials` says whose it is, so a reused slot starts flat. lifts: [MAX_PANES]animation.Spring = @splat(.{}), lift_serials: [MAX_PANES]u32 = @splat(0), /// The focused cursor's quad on its springs (animation.CursorGlide), /// the pane and view origin it was last aimed in, and the last key or /// click, which a blink holds for. cursor_glide: animation.CursorGlide = .{}, /// A pixel shell's own render-time motion is under way (its smooth /// scroll, G6): the core then keeps the continuous path running, so the /// shell draws every loop and a virtual clock moves on, as for a lift. shell_continuous: bool = false, cursor_view: struct { serial: u32 = 0, line: i64 = 0, origin: [2]f32 = .{ 0, 0 }, inside: bool = true } = .{}, /// The cursor glides from one pane to another: it may cross the grid. cursor_crossing: bool = false, input_ns: u64 = 0, animate_theme_changes: bool = false, native_images: bool = false, /// The shell can take a changed image a few rows at a time (a texture row /// upload, a shared-memory hand-off), so a PDF selection is repainted as /// it is dragged instead of once on release. native_image_patches: bool = false, quit: bool = false, /// A detached session with no frontend attached: nothing renders, so /// no input ever dismisses a message (Messages.expireUnattended). unattended: bool = false, haptic: HapticSlot = if (platform == .macos) .none else {}, drag: mouse.Drag = .none, /// A press a terminal's program took (mouse.programMouse): its drags /// and release are the program's too, wherever the pointer goes. mouse_grab: ?mouse.Grab = null, /// Whether the host window has the keyboard (Event.focus), and the pane /// whose program was last told it has it (reportFocus). window_focused: bool = true, focus_told: ?struct { id: usize, serial: u32 } = null, hover_col: u16 = 0, hover_row: u16 = 0, pointer_raw_col: u16 = 0, pointer_raw_row: u16 = 0, pointer_body_hit: ?Mouse.BodyHit = null, pointer_tag_hit: ?TagHit = null, raw_hover_intent: bool = false, pointer_inside: bool = false, /// The last plain B1 click, for acme's double-click (text.c:881, /// textselect: a second click at the same point within 500 ms). last_click: ?struct { id: usize, on_tag: bool, row: i32, col: i32, ms: i64 } = null, /// A B1 chord's edits share the undo point it took (mouse.zig). chord_hold: bool = false, /// The look just run found nothing (look.zig missed). look_missed: bool = false, look_hover_wait: ?look.LookHoverWait = null, /// The place a look-hover last found nothing to open: the pointer /// resting there does not ask again every frame (look.noteLookHover). look_hover_refused: ?look.LookHoverWait = null, look_hover_preview: ?look.LookHoverPreview = null, pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {}, wheel_guard: u8 = 0, ctrl_w_pending: bool = false, 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 (`"`); 0 for the /// default one. register: u21 = 0, leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, /// The keyboard is on a header: the column tag `header_column` names, or /// the workspace's when that is null. header_focus: bool = false, header_column: ?usize = null, /// A mouse sweep is extending the focused header's selection. header_drag: bool = false, /// A middle or right sweep held over a column's or the workspace's tag. header_sweep: ?mouse.HeaderSweep = null, header_scroll: usize = 0, /// The focused header's first line on screen, which follows its caret /// when it has more lines than rows. header_top: usize = 0, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left /// press during an active middle select-drag; consumed on middle release chord_arg: ?[]u8 = null, /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, /// An exec waiting on the REPL question (`Pane.Prompt.repl_choice`): /// the pane it came from, the language, and the text, owned. /// `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. can_ask: bool = false, lsp_seq: u32 = 0, /// The last query asked (Lspwhy explains it): its kind, argument, and /// the pane and place it was asked at. lsp_last: ?struct { kind: lsp.Kind, arg: Effect.Buf(128), serial: u32, offset: u32 } = null, lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, serial: u32, revision: u32, arg: Effect.Buf(128), row: i32 = 0, col: i32 = 0, } = null, pipe: Pipe = .{}, fs: filesystem.Namespace = .{}, boot: Boot = .shell, /// Is there a frame worth drawing? Cleared once one is presented, set by /// anything that can change what is on screen. The first frame always is. needs_frame: bool = true, /// 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. announce: bool = true, // Reject overflow: evicting an older effect would reorder a byte stream. effects: [limits.effect_cap]Effect = undefined, effects_head: usize = 0, effects_len: usize = 0, // Each pty retains its own overflow tail until earlier effects drain. pending_write: [MAX_PANES]?PendingWrite = @splat(null), /// Total bytes parked above, so the drain path costs one comparison when /// nothing is waiting — which is every frame that is not a large paste. pending_write_bytes: usize = 0, /// WHO SERVES THIS CORE. Every method optional; a null one is answered by /// `fallback` below, so a `Host{}` is a complete in-process pardes. host: Host = .{}, /// The in-program answers behind every unimplemented host method. Per /// instance, so several cores behind one fan-out host stay independent. fallback: host_io.Fallback, /// The message-row log (see Messages.zig). messages: Messages = .{}, in_q: [64]Event = undefined, in_head: usize = 0, in_len: usize = 0, 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, /// where the shell wrote the last dump (shell reports back after /// write_dump); shown in the topbar as `Restore ` last_dump: ?[]const u8 = null, /// What a session word (Newcol, New, Exit) runs with when no pane is /// there to run it: the window Delcol emptied. Never installed, made the /// first time it is wanted. stand_in: ?*Pane = null, /// a Restore builtin wants this dump loaded into the live session; the /// shell consumes it via takeRestore each frame (restore contents stay host-fed) restore_req: ?[]const u8 = null, restore_buf: [1024]u8 = undefined, attach_req: ?AttachRequest = null, attach_buf: [attach_name_max]u8 = undefined, surface: Surface = .{}, /// per-update scratch (paneCursorLines, selection text); reset each update scratch: std.heap.ArenaAllocator, /// The arena each frame's Surface text is built in, reset by `pump`. Hosts /// that still own their loop pass their own arena to `render` instead. frame_arena: std.heap.ArenaAllocator, /// the terminal motion surface, memoized against the pane it was built /// for — see panes.terminal.RowsCache for the lifetime rule shell_rows: panes.terminal.RowsCache = .{}, /// Finished command panes' output, and whether it is a diff /// (File.DiffOutput). diff_output: panes.File.DiffOutput = .{}, /// The message row's entry points, called from everywhere as /// `p.setMessage(...)`; they live with the rest of it in Messages.zig. pub const setStatus = Messages.setStatus; pub const setMessage = Messages.setMessage; pub const reportError = Messages.reportError; pub const reportFailure = Messages.reportFailure; /// Running a builtin line, called from everywhere as /// `p.executeBuiltinLine(...)`; it lives with the rest of exec in exec.zig. pub const executeBuiltinLine = exec.executeBuiltinLine; /// The one construction of a session, empty: its allocators, its column /// and workspace tags, the 9P tree's clock and its mounts. `init` boots /// a layout into it and a dump's restore (dump.initDump) its panes; a /// second copy of this drifted once, leaving restored tags without an /// allocator and every 9P mtime 0. pub fn create(gpa: std.mem.Allocator, opts: Options) !*Pardes { const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .image_gpa = opts.image_allocator orelse gpa, .pdf_gpa = opts.pdf_allocator orelse gpa, .tree_sitter_gpa = opts.tree_sitter_allocator orelse 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, .scratch = .init(gpa), .frame_arena = .init(opts.frame_allocator orelse gpa), .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); p.fs.started = ctlfs.events.now(); for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; return p; } pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const p = try create(gpa, opts); errdefer p.deinit(); p.boot = Boot.of(opts); switch (p.boot) { .document => { const path = opts.file.?; const opened = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line, .last); if (look.isImagePath(path)) break :initial_doc panes.Image.create(p, 0, path, &.{}); break :initial_doc panes.File.open(p, 0, path, opts.file_line); }; if (opened) |_| {} else |err| { if (err == error.OutOfMemory) return err; const why = switch (@as(anyerror, err)) { error.PermissionDenied => "permission denied", error.FileNotFound => "no file of that name", error.IsDirectory => "that is a directory, and not one that could be read", error.PathTooLong => "that path is too long", error.FileTooLarge => "that file is too large to open", error.NotAFile => "that is a pipe or a device, not a document", else => @errorName(err), }; const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why }); errdefer gpa.free(content); _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); } p.oneColumn(); }, .missing => { const missing = opts.missing.?; const content = try std.fmt.allocPrint( gpa, "file or directory not found\n\n\t{s}\n", .{missing.word}, ); errdefer gpa.free(content); _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.oneColumn(); }, .stdin => { const content = try gpa.dupe(u8, opts.stdin_text.?); errdefer gpa.free(content); const dir = p.sessionDir(); const shown = try panes.Output.open(p, 0, dir, .{ .cmd = .New }, "", content); // Named +Pager, as `pardes -` inside a session names its pane. const named = try std.fs.path.join(gpa, &.{ dir, "+Pager" }); gpa.free(shown.file.?.path); shown.file.?.path = named; shown.file.?.pager_diff = panes.File.looksLikeDiff(content[0..@min(content.len, 64 * 1024)]); p.oneColumn(); }, .embedded => { const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content); p.oneColumn(); }, .tty, .tty_shell => { _ = try p.newShell(0, ""); p.panes[0].?.body.mode = .tty; p.oneColumn(); // A bare tty is still an editor: it opens with somewhere to put // text under the shell. The shell keeps the keyboard. A boot // layout, like .classic's, not a placement. if (p.boot == .tty) { const np = try p.newScratch(0, 1, .scratch); layout.insert(p, 0, 1, 1); layout.splitBelow(p, 0, np); } }, .classic => { _ = try p.newShell(0, ""); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); for (p.panes[0..3]) |slot| slot.?.greet = true; p.ncol = 2; p.col_n[0] = 2; p.col_panes[0][0] = 0; p.col_panes[0][1] = 1; p.col_n[1] = 1; p.col_panes[1][0] = 2; }, .shell => { _ = try p.newShell(0, ""); p.panes[0].?.greet = true; p.oneColumn(); }, } p.sync(); p.loadRecent(); exec.applyStartupConfig(p); colors.finishThemeInitialization(p); p.sync(); p.presentation.enabled = true; // A config init that opens a file with `Look …` armed the pulse before // anyone touched anything. Nobody asked for that, so boot is silent. _ = p.takeHaptic(); // The ring is reserved whole now, so recording never allocates later. try p.fs.log.buf.ensureTotalCapacityPrecise(p.gpa, p.fs.log.cap); ctlfs.events.announce(p); // /log starts with the panes it booted with return p; } /// The layout every single-pane boot starts from. fn oneColumn(p: *Pardes) void { p.ncol = 1; p.col_n[0] = 1; p.col_panes[0][0] = 0; } /// The recent list's own allocator: it is kept best effort (a file not /// remembered for want of memory is no failure of what opened it), so /// on a host it takes from the process heap, not the editor's, whose /// every failure must be answered. // ponytail: a host heap for a best-effort list; the board's gpa there. pub fn recentGpa(p: *const Pardes) std.mem.Allocator { return if (comptime hosted) std.heap.smp_allocator else p.gpa; } /// The kept list of files opened lately, read once at start. pub fn loadRecent(p: *Pardes) void { const path = p.opts.recent_path orelse return; if (comptime !hosted) return; defer p.recent_loaded = true; const bytes = filesystem.readFile(p.gpa, path) catch return; defer p.gpa.free(bytes); p.recent.read(p.recentGpa(), bytes); } /// Written out when it changed, at the end of a step and at the end. pub fn saveRecent(p: *Pardes) void { if (!p.recent.dirty) return; p.recent.dirty = false; const path = p.opts.recent_path orelse return; if (comptime !hosted) return; if (!p.recent_loaded) return; var out: std.Io.Writer.Allocating = .init(p.gpa); defer out.deinit(); p.recent.write(&out.writer) catch return; turn.yield(); defer turn.back(); std.Io.Dir.cwd().writeFile(std.Io.Threaded.global_single_threaded.io(), .{ .sub_path = path, .data = out.written() }) catch {}; } /// A file pane's path and dot, for the recent list (0 when none). /// Whether `path` is one Recent keeps: a file on this host's disk, not /// the served tree's (`/virtual/`) nor a name never saved. pub fn recentKeeps(path: []const u8) bool { const local = filesystem.localPath(path) orelse return false; if (comptime !hosted) return true; return exec.isFile(local); } /// A file, PDF or image pane closing: its place noted in Recent, when /// Recent keeps it. fn noteClosed(p: *Pardes, pane: *const Pane) void { const d = recentPlace(pane) orelse return; if (p.recent.get(d.path) == null and !recentKeeps(d.path)) return; p.recent.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol); } pub const RecentPlace = struct { path: []const u8, line: u32, col: u32, aline: u32 = 0, acol: u32 = 0 }; /// Where a pane is, as Recent keeps it: a file's dot, a PDF's page /// (`:page:1`), an image's path alone (line 0). Null for any other. pub fn recentPlace(pane: *const Pane) ?RecentPlace { if (recentDot(pane)) |d| return .{ .path = d.path, .line = d.line, .col = d.col, .aline = d.aline, .acol = d.acol }; if (pane.pdfPath()) |path| return .{ .path = path, .line = @intCast(pane.pdfPage().? + 1), .col = 1 }; if (pane.image) |iv| return .{ .path = iv.path, .line = 0, .col = 0 }; return null; } fn recentDot(pane: *const Pane) ?struct { path: []const u8, line: u32, col: u32, aline: u32 = 0, acol: u32 = 0 } { const f = pane.file orelse return null; if (f.output != null) return null; // The selection's anchor too, so a reopen restores both its ends. const sel = pane.body.vsel.active; return .{ .path = f.path, .line = @intCast(@max(0, pane.body.cur_row) + 1), .col = @intCast(@max(0, pane.body.cur_col) + 1), .aline = if (sel) @intCast(@max(0, pane.body.vsel.row) + 1) else 0, .acol = if (sel) @intCast(@max(0, pane.body.vsel.col) + 1) else 0, }; } /// The recent entry a closed jump names, by its path's hash. pub fn recentByHash(p: *const Pardes, hash: u64) ?recent_files.Entry { for (p.recent.entries.items) |e| if (std.hash.Wyhash.hash(0, e.path) == hash) return e; return null; } pub fn deinit(p: *Pardes) void { // Every file open now is closed with its dot, and the list kept. for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane); p.saveRecent(); p.recent.deinit(p.recentGpa()); p.fs.batch.bytes.deinit(p.gpa); if (p.stand_in) |pane| p.teardownPane(pane); look.cancelLookHover(p); if (p.repl_ask) |ask| p.gpa.free(ask.text); for (0..MAX_PANES) |id| p.dropPendingWrite(id); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; for (&p.retired_panes.current) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; const gpa = p.gpa; for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); 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); if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value); if (p.chord_arg) |a| gpa.free(a); for (p.spawn_dirs) |d| if (d) |dir| gpa.free(dir); if (p.pipe.wait) |*wait| wait.deinit(gpa); p.locations_cache.deinit(p.tree_sitter_gpa); p.fs.deinit(gpa); p.shell_rows.reset(gpa); p.diff_output.reset(gpa); p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); for (&p.surface.body_layers) |*layer| gpa.free(layer.cells); for (&p.surface.tag_layers) |*layer| gpa.free(layer.cells); p.presentation.deinit(gpa); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } /// shell feedback after write_dump: remember where the dump landed (the /// topbar grows a `Restore ` word pointing at it) pub fn setLastDump(p: *Pardes, path: []const u8) void { p.pointRestoreAt(path); ctlfs.events.notePath(p, "dump", path); } /// The workspace tag's `Restore ` names `path` from now on: the /// default's does by itself, and one the tag was edited around (its own /// text) has the old path swapped, never left pointing at a dump that is /// not the last. pub fn pointRestoreAt(p: *Pardes, path: []const u8) void { const copy = p.gpa.dupe(u8, path) catch return; if (p.last_dump) |old| { if (p.global_tag.own) |own| swap: { const a = p.scratch.allocator(); const was = std.fmt.allocPrint(a, "Restore {s}", .{old}) catch break :swap; if (std.mem.indexOf(u8, own, was) == null) break :swap; const now = std.fmt.allocPrint(a, "Restore {s}", .{path}) catch break :swap; const swapped = std.mem.replaceOwned(u8, p.gpa, own, was, now) catch break :swap; p.gpa.free(own); p.global_tag.own = swapped; } p.gpa.free(old); } p.last_dump = copy; } /// the shell polls this each frame: a pending Restore's dump path, or null pub fn takeRestore(p: *Pardes) ?[]const u8 { const r = p.restore_req; p.restore_req = null; return r; } /// the shell polls this each frame beside takeRestore: a pending Attach's /// session and reporting pane, or null pub fn takeAttach(p: *Pardes) ?AttachRequest { const a = p.attach_req; p.attach_req = null; return a; } /// The workspace tag's height: one row while the setting keeps it, as /// acme's row tag is, zero once a native shell takes its commands over. /// Layout and hit testing read this rather than the constant, the way /// they read `columnBarHeight`. pub fn topBarHeight(p: *const Pardes) u16 { if (!p.settings.workspace_tag) return 0; return TOPBAR_H; } /// The column tags' height: one row, as acme's (cols.c:244, colresize, /// gives a column tag one font height); a newline in one is a space /// (tagline.flattenHeaders). pub fn columnBarHeight(p: *const Pardes) u16 { if (p.screen_h < 3) return 0; return COLBAR_H; } fn detachCwds(p: *Pardes, parents: []const *Pane) !void { var copies: [MAX_PANES]?[]u8 = @splat(null); errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const source = switch (pane.cwd) { .inherited => |parent| parent, else => continue, }; for (parents) |parent| if (source == parent) { copies[id] = try p.gpa.dupe(u8, paneDir(source)); break; }; } for (copies, 0..) |copy, id| if (copy) |bytes| { p.panes[id].?.cwd = .{ .owned = bytes }; }; } // The slot closes now; allocation teardown waits one frame. Caller clears panes[id]. pub fn deinitPane(p: *Pardes, pane: *Pane) !void { try p.detachCwds(&.{pane}); p.retirePane(pane); } fn retirePane(p: *Pardes, pane: *Pane) void { // Closed, a file is remembered with its dot: Recent and a jump back // to it reopen it there. p.noteClosed(pane); panes.Output.dropUnsaved(p, pane); const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf(); for (p.panes, 0..) |slot, id| if (slot == pane) { if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); if (pane.isTerminal()) p.emit(.{ .close_pty = .{ .pane = @intCast(id) } }); ctlfs.events.noteRetire(p, pane); // ...and so do bytes still queued for the pty it no longer has. p.dropPendingWrite(id); }; if (look.lookHoverPane(p)) |h| if (h < p.panes.len and p.panes[h] == pane) look.cancelLookHover(p); p.retired_panes.retire(pane); } // Retired panes keep their payloads until the following frame or core teardown. fn teardownPane(p: *Pardes, pane: *Pane) void { if (look.lookHoverPane(p)) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) look.cancelLookHover(p); } p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); panes.terminal.deinitPendingCommand(pane); if (pane.command) |line| p.gpa.free(line); if (pane.shell) |bin| p.gpa.free(bin); if (pane.image) |*iv| { iv.deinit(p.image_gpa); } if (comptime pdf_enabled) if (pane.pdf) |*pv| panes.Pdf.deinitPane(p, pane, pv); if (pane.file) |*file| panes.File.deinit(p, pane, file); if (pane.ovl) |o| p.gpa.free(o.text); 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); p.gpa.destroy(pane); } fn reapPanes(p: *Pardes) void { for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; p.retired_panes.previous = p.retired_panes.current; p.retired_panes.current = @splat(null); } pub fn installPane(p: *Pardes, id: usize, pane: *Pane) void { std.debug.assert(p.panes[id] == null); p.next_serial += 1; pane.serial = p.next_serial; p.rects[id] = .{}; p.panes[id] = pane; panes.terminal.adoptSettings(p, pane); // /log hears of it once the update that made it ends, when it has // its file, name and place (`ctlfs.events.announce`). pane.fs.unannounced = true; } pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > limits.host_path_cap) return error.PathTooLong; const pane = try panes.terminal.create(p.gpa, p.screen_w, p.screen_h); // Named by the directory it is started in from the first, so the // shell saying it is there is no rename (`new N /` then `rename`); // out of memory for it, the shell names it when it says. if (cwd.len > 0) pane.setOwnedCwd(cwd) catch {}; panes.terminal.armShellSpawn(pane); p.installPane(id, pane); p.emitSpawn(id, pane.serial, cwd); return pane; } /// A child for pane `id` in `cwd`: a directory up to PATH_MAX, the ones /// past the effect's inline buffer kept here for perform to give. pub fn emitSpawn(p: *Pardes, id: usize, serial: u32, cwd: []const u8) void { // A new shell has not said anything yet (setCwd's guard), whatever // the one it replaces said. if (p.panes[id]) |pane| pane.shell_spoke = false; if (p.spawn_dirs[id]) |old| p.gpa.free(old); p.spawn_dirs[id] = null; if (cwd.len > effect_path_cap) p.spawn_dirs[id] = p.gpa.dupe(u8, cwd) catch null; p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = serial, .cwd = .from(if (cwd.len > effect_path_cap) "" else cwd) } }); } /// A command pane: a terminal whose child is `$SHELL -c line` rather /// than an interactive shell, left showing its output and `exit N` when /// it is done (exec.zig runCommand). pub fn newCommand(p: *Pardes, id: usize, cwd: []const u8, line: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > limits.host_path_cap) return error.PathTooLong; const owned = try p.gpa.dupe(u8, line); 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. pane.setOwnedCwd(cwd) catch {}; p.installPane(id, pane); p.emitSpawn(id, pane.serial, cwd); return pane; } pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h); p.installPane(id, pane); return pane; } fn newScratch(p: *Pardes, from_id: usize, free: usize, kind: exec.NewPane) !*Pane { // None asking (an empty window): the session's, as pane/new's is. const src_or = p.panes[from_id]; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); // A terminal that has not said where it is yet (the boot layout's) // has no directory: the scratch is the session's, /+New. So is // 9P's pane/new, and a New from a column's or the workspace's tag: // no pane asked for it, whichever last had the keyboard (acme's new // window has acme's directory). const src = if (kind != .fs and !p.exec_header) src_or else null; const dir = if (src) |s| paneDir(s) else ""; const np = try panes.Output.open(p, free, if (dir.len == 0) p.sessionDir() else dir, .{ .cmd = .New }, "", content); if (src) |s| np.cwd = .{ .inherited = s }; np.body.cur_pinned = true; return np; } /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew /// places it. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { p.newScratchFor(from_id, .scratch); } /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`). pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void { const free = p.freeSlot() orelse return p.reportError(from_id, "New", error.NoPaneSlots); _ = p.newScratch(from_id, free, kind) catch return; exec.placeNew(p, from_id, from_id, free, kind); } /// Newcol: an empty column right of the calling pane's, the keyboard /// on its tag, for the next pane made to fill. pub fn newColumn(p: *Pardes, from_id: usize) void { // An empty window (Delcol took the last column): its first column. if (layout.firstColumn(p)) |c| return tagline.enterHeader(p, c); const col = p.keyboardColumn(from_id) orelse return; // A trial: laying it out gives it a serial (layout.compute), which a // refusal takes back. Every other column has its own already, so // the counter as it is now is the trial's to return to. for (0..p.ncol) |k| _ = layout.columnSerial(p, k); const unspent = p.next_column_serial; // Said, not silently nothing: "no space" is 9ns's word for ENOSPC. const c = layout.insertColumnUnannounced(p, col, false) orelse { var said: [96]u8 = undefined; return p.reportFailure(from_id, if (p.ncol >= MAX_COLS) std.fmt.bufPrint(&said, "Newcol: no space for a column: {d} max", .{MAX_COLS}) catch "Newcol: no space for a column" else "Newcol: no space for a column: this one is too narrow to split"); }; // A narrower column wraps long tags onto more rows, which can leave // a pane under its tag and two rows: its column's rows are shared // out again, and where they cannot hold every minimum there is no // new column, as a size too small is refused. for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) { layout.dropColumnUnannounced(p, c); p.next_column_serial = unspent; layout.compute(p); return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit"); }; // Kept: now it has a serial, and the log hears of it. layout.noteColumn(p, "newcol", layout.columnSerial(p, c)); tagline.enterHeader(p, c); } pub fn setActiveColumn(p: *Pardes, c: usize) void { if (c < p.ncol) p.active_column = layout.columnSerial(p, c); } /// The active column, while it is still there. pub fn activeColumn(p: *const Pardes) ?usize { return layout.columnBySerial(p, p.active_column); } /// The column the keyboard is in: its tag's, or its pane's. /// DECSET 1004: a program that asked is told `CSI I` when its pane /// takes the keyboard (the active pane, its body, the window focused) /// and `CSI O` when it gives it up, to another pane, a tag or header, /// or another window. fn reportFocus(p: *Pardes) void { const want: @FieldType(Pardes, "focus_told") = blk: { if (!p.window_focused or p.header_focus) break :blk null; const pane = p.panes[p.active] orelse break :blk null; if (pane.focus != .body) break :blk null; break :blk .{ .id = p.active, .serial = pane.serial }; }; if (std.meta.eql(want, p.focus_told)) return; if (p.focus_told) |old| if (p.panes[old.id]) |pane| { if (pane.serial == old.serial and panes.terminal.reportsFocus(pane)) p.emitWrite(old.id, "\x1b[O"); }; if (want) |new| if (panes.terminal.reportsFocus(p.panes[new.id].?)) p.emitWrite(new.id, "\x1b[I"); p.focus_told = want; } pub fn noteTyped(p: *Pardes) void { if (p.header_focus) { if (p.header_column) |c| p.setActiveColumn(c); } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col); } /// New, run from column `c`'s tag. pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void { p.exec_column = c; defer p.exec_column = null; p.newScratchBelow(from_id); } /// The column a command acts on: the one whose tag it came from, else /// the one whose tag has the keyboard, else `id`'s. pub fn keyboardColumn(p: *Pardes, id: usize) ?usize { if (p.exec_column) |c| return c; if (p.header_focus) if (p.header_column) |c| return c; return if (layout.findPane(p, id)) |f| f.col else null; } pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { if (slot == null and !p.reserved_slots[i]) break i; } else null; } /// The slot holding the pane with this serial, if it is still open. pub fn paneBySerial(p: *Pardes, serial: u32) ?usize { return for (p.panes, 0..) |slot, i| { if (slot) |pane| if (pane.serial == serial) break i; } else null; } pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; // Anything already parked for this pane owns the stream's position, so // new bytes queue BEHIND it — emitting them now would reorder the pty. if (p.pending_write[id] == null) { while (off < bytes.len and p.effects_len < p.effects.len) { const n = @min(bytes.len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } }); off += n; } } 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; if (p.pending_write[id]) |*pw| { // Compact what the drain already sent before growing: a burst that // arrives in pieces must not keep re-copying bytes nobody wants. const keep = pw.bytes.len - pw.off; const grown = p.gpa.alloc(u8, keep + bytes.len) catch return; @memcpy(grown[0..keep], pw.bytes[pw.off..]); @memcpy(grown[keep..], bytes); p.gpa.free(pw.bytes); pw.* = .{ .bytes = grown }; } else { p.pending_write[id] = .{ .bytes = p.gpa.dupe(u8, bytes) catch return }; } p.pending_write_bytes += bytes.len; } /// The shell reports the spawned pane's working directory (and later cwd /// changes it observes, e.g. via /proc//cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; // A command pane stays the directory's it was run for (newCommand). if (pane.command != null) return; const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", }; if (std.mem.eql(u8, cur, cwd)) return; // A shell started in a directory is asked where it is before it has // said anything: its chdir may not have happened yet, and the answer // is this process's directory, a rename to undo. That answer, then, // is not taken. if (pane.isTerminal() and !pane.shell_spoke and pane.cwd == .owned and std.mem.eql(u8, cwd, p.sessionDir())) return; // One with no directory yet is named by the session's (nameOf): its // shell saying it is there is no new name. const same_name = cur.len == 0 and std.mem.eql(u8, cwd, p.sessionDir()); pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); if (look.lookHoverPane(p) == id) look.cancelLookHover(p); // A terminal is named by its directory, so /log hears it renamed; one // not announced yet is announced under the new name. if (pane.isTerminal() and !same_name) ctlfs.events.noteLog(p, .rename, pane); } pub fn shellBin(p: *const Pardes) []const u8 { const selected = p.settings.shell.requested.get(); return if (selected.len == 0) config.defaultShell() else selected; } pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void { if (id < MAX_PANES) if (p.panes[id]) |pane| { ctlfs.pty.shellGone(p, pane, true); // a respawn replaced whatever ran pane.shell_failed = false; pane.had_shell = true; pane.fs.unmarked = !prompt_marks; // The tag's Tty word names the shell the host ran, and a click // on it (`Tty+fish`) opens another of the same. if (pane.command == null) { // A shell given as a path keeps it: its bare name would find // nothing, or another program, on the usual paths. const base = if (pane.shell) |given| (if (std.mem.indexOfScalar(u8, given, '/') != null) given else std.fs.path.basename(executable)) else std.fs.path.basename(executable); const rest = tagline.terminal_pane_tail[" Tty".len..]; if (std.fmt.bufPrint(&pane.shell_tail, " Tty+{s}{s}", .{ base, rest })) |tail| { pane.shell_tail_len = @intCast(tail.len); } else |_| {} } }; if (id < MAX_PANES) panes.terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) p.settings.shell.pending = false; } pub fn takeFontRequest(p: *Pardes) ?[]const u8 { if (comptime !font_picker) return null; if (!p.settings.font.pending or p.font_request_taken) return null; const path = p.settings.font.requested_path.get(); if (path.len == 0) return null; p.font_request_taken = true; return path; } pub fn observeFont( p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.effective_name.set(effective_name)) return false; p.settings.font.effective_size_hundredths = effective_size_hundredths; p.settings.font.effective_size_unit = unit; return true; } /// Resolve the request already handed to the GUI host. Requested and /// effective tuples deliberately remain separate so DumpConfig can show both. pub fn acknowledgeFont( p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.pending or !p.font_request_taken) return false; const changed = !std.mem.eql( u8, p.settings.font.effective_name.get(), effective_name, ) or p.settings.font.effective_size_hundredths != effective_size_hundredths or p.settings.font.effective_size_unit != unit; if (!p.observeFont(effective_name, effective_size_hundredths, unit)) return false; p.settings.font.pending = false; p.font_request_taken = false; if (changed) p.abandonPanelAnimations(); return true; } pub fn rejectFont(p: *Pardes) void { if (comptime !font_picker) return; if (!p.settings.font.pending or !p.font_request_taken) return; p.settings.font.pending = false; p.font_request_taken = false; } fn applyWatchedFileChanged(p: *Pardes, id: u8, bytes: []const u8) bool { if (id >= MAX_PANES) return false; const pane = p.panes[id] orelse return false; if (comptime pdf_enabled) if (pane.pdf != null) { panes.Pdf.reloadWatched(p, pane) catch |err| { p.reportError(id, "PDF reload", err); return false; }; return true; }; if (pane.file == null) return false; panes.File.changed(p, id, bytes); return if (p.panes[id]) |current| if (current.file) |file| std.mem.eql(u8, file.content, bytes) else false else false; } /// Something changed the core without going through `update` -- a watched /// file reloading under it, a theme file rewritten on disk. `pump` draws /// only when it has a reason to, and this is the reason. pub fn invalidate(p: *Pardes) void { p.needs_frame = true; } pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool { p.invalidate(); look.invalidateLookHover(p, id); const applied = p.applyWatchedFileChanged(id, bytes); _ = p.scratch.reset(.retain_capacity); p.sync(); return applied; } pub fn emit(p: *Pardes, e: Effect) void { if (p.effects_len == p.effects.len) return; const tail = (p.effects_head + p.effects_len) % p.effects.len; p.effects[tail] = e; p.effects_len += 1; } pub fn nextEffect(p: *Pardes) ?Effect { p.refillPendingWrites(); if (p.effects_len == 0) { p.effects_head = 0; return null; } const e = p.effects[p.effects_head]; p.effects_head = (p.effects_head + 1) % p.effects.len; p.effects_len -= 1; return e; } /// Move parked pty bytes into whatever room the ring now has, oldest pane /// slot first. Costs one comparison when nothing is parked. fn refillPendingWrites(p: *Pardes) void { if (p.pending_write_bytes == 0) return; for (&p.pending_write, 0..) |*slot, id| { while (p.effects_len < p.effects.len) { // `|*pw|` points INTO the slot: capturing by value would advance // a copy's cursor and re-send the same chunk for ever. const pw = if (slot.*) |*live| live else break; const n = @min(pw.bytes.len - pw.off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pw.bytes[pw.off..][0..n]) } }); pw.off += n; p.pending_write_bytes -= n; if (pw.off == pw.bytes.len) { p.gpa.free(pw.bytes); slot.* = null; break; } } if (p.effects_len == p.effects.len) return; } } /// The pane a session word runs with in an empty window. pub fn standIn(p: *Pardes) ?*Pane { if (p.stand_in == null) p.stand_in = panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null; return p.stand_in; } /// Drop a pane's parked bytes: its pty is gone, and the slot it occupied /// may be handed to a different pane next frame. fn dropPendingWrite(p: *Pardes, id: usize) void { const pw = p.pending_write[id] orelse return; p.pending_write_bytes -= pw.bytes.len - pw.off; p.gpa.free(pw.bytes); p.pending_write[id] = null; } pub fn postEvent(p: *Pardes, ev: Event) void { switch (ev) { .key => |k| std.debug.assert(k.text.len == 0), .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command => unreachable, else => {}, } if (p.in_len == p.in_q.len) return; p.in_q[(p.in_head + p.in_len) % p.in_q.len] = ev; p.in_len += 1; } fn nextQueued(p: *Pardes) ?Event { if (p.in_len == 0) { p.in_head = 0; return null; } const ev = p.in_q[p.in_head]; p.in_head = (p.in_head + 1) % p.in_q.len; p.in_len -= 1; return ev; } pub fn hostForegroundName(p: *const Pardes, id: usize, buf: []u8) ?[]const u8 { const f = p.host.vtable.fg_name orelse return null; return f(p.host.ctx, @intCast(id), buf); } pub fn hostTtyTaken(p: *const Pardes, id: usize) bool { const f = p.host.vtable.tty_taken orelse return false; return f(p.host.ctx, @intCast(id)); } pub fn hostKillJob(p: *const Pardes, id: usize) bool { const f = p.host.vtable.kill_job orelse return false; return f(p.host.ctx, @intCast(id)); } fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void { if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes); // The in-process filesystem reports the same way a real host does, so // an OOM here leaves the pane dirty rather than looking saved. if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, path, error.OutOfMemory); } /// A write of `path` the host could not do: the pane stays dirty, the /// message row says `Save : `, and the 9P write that asked /// for it, waiting on the save, fails with that (`fs.late_failure`). /// A scratch written to `path` is that file from here on: named, an /// ordinary file pane, clean, and watched (exec.saveTo). fn promoteSaved(p: *Pardes, id: u8, path: []const u8) void { const pane = p.panes[id] orelse return; const f = if (pane.file) |*file| file else return; const owned = p.gpa.dupe(u8, path) catch return; p.gpa.free(f.path); f.path = owned; f.output = null; pane.clearCwd(); // re-derive the tag as a plain file's if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = null; f.saved_revision = f.revision; f.disk_gone = false; ctlfs.events.noteLog(p, .save, pane); p.recent.opened(p.recentGpa(), f.path); if (filesystem.localPath(f.path) != null) p.emit(.{ .watch = .{ .pane = id, .on = true } }); } /// A terminal whose shell could not be started: said, and a run waiting /// on it, or written to it from now on, hears `error shell gone` /// rather than wait for a prompt that never comes. pub fn shellFailed(p: *Pardes, id: u8, err: anyerror) void { const pane = p.panes[id] orelse return; pane.shell_failed = true; ctlfs.pty.shellGone(p, pane, false); p.sayShellFailure(id, err); // A command that never started ended: `exit 127`, as a shell says of // a command it could not run, so a follower waiting on its exit // hears one, its tag stops saying it runs, and Kill finds nothing. if (pane.command != null and !pane.command_done) { pane.command_pty = false; exec.commandDone(p, id, 127); } else if (pane.command == null and !pane.had_shell) { // A Tty whose shell never ran: no dead pane left behind; the // write that asked for it has failed with why. A terminal whose // shell was started again (pty/ctl exec) keeps its pane. p.unplaced.set(id); } } /// A shell started again in a terminal whose shell runs (pty/ctl's /// exec) did not start: the host kept the running one, so only said. pub fn restartFailed(p: *Pardes, id: u8, err: anyerror) void { if (p.panes[id] == null) return; p.sayShellFailure(id, err); } fn sayShellFailure(p: *Pardes, id: u8, err: anyerror) void { const pane = p.panes[id] orelse return; if (err == error.FileNotFound or err == error.NotDir) { p.reportFailure(id, "shell: directory missing (no such directory)"); } else if (err == error.InterpreterNotFound) { // The script is there; the program its `#!` names is not. var said: [320]u8 = undefined; var interp: [256]u8 = undefined; var buf: [std.fs.max_path_bytes]u8 = undefined; const Shell = if (comptime hosted) @import("host_io.zig").Shell else void; const name: ?[]const u8 = if (comptime hosted) (if (Shell.find(pane.shell orelse p.shellBin(), &buf)) |f| Shell.missingInterpreter(f, &interp) else null) else null; p.reportFailure(id, if (name) |n| std.fmt.bufPrint(&said, "shell: interpreter {s} not found", .{n}) catch "shell: interpreter not found" else "shell: interpreter not found"); } else { p.reportError(id, "shell", err); } // A 9P write that asked for the shell (pty/ctl's exec), waiting on // it, fails with what was said, as a failed Save's does. p.noteLateFailure(id); } /// A Save makes the directories its file goes in, and any above them, /// first: one rule, for the pane's own name and `Save path` alike. A /// directory it cannot make leaves the write to fail and say why. fn makeParents(path: []const u8) void { if (comptime !hosted) return; const local = filesystem.localPath(path) orelse return; filesystem.makeDirs(std.fs.path.dirname(local) orelse "/"); } pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void { if (p.panes[id]) |pane| if (pane.file) |*f| { // The `-%` spelling fs.zig already uses for "make this dirty". f.saved_revision = f.revision -% 1; }; var what: [limits.host_path_cap + 32]u8 = undefined; // A write into a directory that is not there: said so, in the words // a mount reads as ENOENT. const parent = std.fs.path.dirname(path) orelse "/"; const no_dir = err == error.FileNotFound or err == error.NotDir or (if (comptime hosted) (if (filesystem.localPath(parent)) |local| !exec.isDirectory(local) else false) else false); // Not there because one above refuses: that is the reason, EACCES. const denied = no_dir and (if (comptime hosted) (if (filesystem.localPath(parent)) |local| filesystem.deniedAbove(local) else false) else false); if (denied) p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: permission denied", .{path}) catch "Save: permission denied") else if (no_dir) p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: no such directory", .{path}) catch "Save: no such directory") else p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err); p.noteLateFailure(id); } /// A Dump the host could not write: said on the message row and in the /// log, and the 9P write that asked for it fails with it, as a failed /// Save's does. pub fn dumpFailed(p: *Pardes, path: []const u8, err: anyerror) void { var what: [limits.host_path_cap + 8]u8 = undefined; const operation = std.fmt.bufPrint(&what, "Dump {s}", .{path}) catch "Dump"; // Into a DumpDir that is not there: said so, ENOENT, as a Save is; // one that is there but no directory (`/dev/null`) says that. const parent = std.fs.path.dirname(path) orelse "/"; const kind: ?std.Io.File.Kind = if (comptime hosted) (if (filesystem.localPath(parent)) |local| exec.kindOf(local) else .directory) else .directory; const no_dir = err == error.FileNotFound or err == error.NotDir or kind != .directory; if (kind != null and kind != .directory) { var said: [limits.host_path_cap + 48]u8 = undefined; p.reportFailure(p.active, std.fmt.bufPrint(&said, "{s}: {s} is not a directory", .{ operation, parent }) catch "Dump: not a directory"); } else if (no_dir) { var said: [limits.host_path_cap + 48]u8 = undefined; p.reportFailure(p.active, std.fmt.bufPrint(&said, "{s}: no such directory", .{operation}) catch "Dump: no such directory"); } else p.reportError(p.active, operation, err); // In the words the message row has, so the write's err is the one // record of it (its msg goes: 9p_io, dropMessage). p.noteLateFailure(p.active); } /// The path a watch is about: a real file's, or a PDF's. fn watchPath(p: *const Pardes, id: u8) ?[]const u8 { const pane = p.panes[id] orelse return null; if (pane.file) |f| return if (f.output == null) f.path else null; return pane.pdfPath(); } pub fn fsPayload(p: *const Pardes, r: ctlfs.Reply) []const u8 { return switch (r.payload) { .none => &.{}, .staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)], .region => |g| region: { const pane = p.panes[g.pane] orelse break :region &.{}; if (pane.serial != g.serial) break :region &.{}; const text = if (pane.file) |*f| f.content else break :region &.{}; const lo = @min(g.off, text.len); break :region text[lo..@min(lo + g.len, text.len)]; }, }; } pub fn perform(p: *Pardes, e: Effect) void { const v = p.host.vtable; switch (e) { // The host reads the pane (its command, its shell) as it forks, // so the slot must still hold the pane the spawn was for. .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, if (s.cwd.len == 0) (p.spawn_dirs[s.pane] orelse "") else s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its // screen, and the bytes are dropped rather than transcribed. .write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), .resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), // A signal for a pane that closed, its slot retaken in the same // step, would reach another pane's child. .signal_pty => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), .close_pty => |c| if (v.close_pty) |f| f(p.host.ctx, c.pane), .open_link => |u| if (v.open_link) |f| f(p.host.ctx, u.slice()) else p.fallback.setLink(u.slice()), .save_file => |sf| { const pane = p.panes[sf.pane] orelse return; const f = if (pane.file) |*file| file else return; const serial = pane.serial; const was = f.saved_revision; const failures = p.fs.failures; f.saved_revision = f.revision; makeParents(f.path); p.hostWriteFile(sf.pane, f.path, f.content); const saved_pane = p.panes[sf.pane] orelse return; if (saved_pane.serial != serial) return; const saved = if (saved_pane.file) |*file| file else return; // A save the host could not do (saveFailed) is its err // record alone, not a `save`, and changes nothing: a clean // file stays clean. if (p.fs.failures != failures) { saved.saved_revision = was; return; } // Edited while it was written: what is on disk is not this. if (saved.saved_revision != saved.revision) return; saved.disk_gone = false; ctlfs.events.noteLog(p, .save, saved_pane); // On disk now, a new file too: Recent has it, open. if (saved.output == null and recentKeeps(saved.path)) p.recent.opened(p.recentGpa(), saved.path); if (!saved.watch_after_save) return; saved.watch_after_save = false; if (filesystem.localPath(saved.path) != null) p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } }); }, .save_text => |st| { const pane = p.panes[st.pane] orelse return; if (pane.serial != st.serial) return; // a recycled slot: not ours if (pane.file) |*f| { const was = f.saved_revision; const failures = p.fs.failures; makeParents(st.path.slice()); p.hostWriteFile(st.pane, st.path.slice(), f.content); const after = p.panes[st.pane] orelse return; if (after.serial != st.serial) return; const g = if (after.file) |*file| file else return; // Failed, it changes nothing: not the name, not dirty. if (p.fs.failures != failures) { g.saved_revision = was; return; } if (st.promote) p.promoteSaved(st.pane, st.path.slice()) else ctlfs.events.noteLogAs(p, .save, after, st.path.slice()); return; } if (!pane.isTerminal()) return; const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); makeParents(st.path.slice()); const failures = p.fs.failures; p.hostWriteFile(st.pane, st.path.slice(), text); if (p.fs.failures == failures) if (p.panes[st.pane]) |still| if (still.serial == st.serial) ctlfs.events.noteLogAs(p, .save, still, st.path.slice()); }, .write_dump => { const out = p.dump_out orelse return; if (v.write_dump) |f| { f(p.host.ctx, out); } else { _ = p.fallback.writeFile(host_io.Fallback.dump_path, out); p.setLastDump(host_io.Fallback.dump_path); } }, .set_clipboard => { 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, // which is the same shape as a host answering later. .read_clipboard => if (v.read_clipboard) |f| f(p.host.ctx) else p.update(.{ .paste = p.fallback.clipboard.items }), .lsp => |q| if (v.lsp) |f| f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() }) else p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), .pipe => |q| if (v.pipe) |f| f(p.host.ctx, q.id) else p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), .watch => |w| if (v.watch_file) |f| f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode) else { p.fallback.watched[w.pane] = w.on; }, .theme_file => |t| if (v.watch_theme) |f| f(p.host.ctx, t.generation, t.on), .dump_themes => |d| if (v.dump_themes) |f| f(p.host.ctx, d.pane), .attach => |a| { const name = a.name.slice(); @memcpy(p.attach_buf[0..name.len], name); p.attach_req = .{ .pane = a.pane, .name = p.attach_buf[0..name.len] }; }, .detach => |d| if (v.detach) |f| f(p.host.ctx) else p.reportError(d.pane, "detach", error.NotAttached), // the loop's own condition; a host tears down after its own loop .quit => p.quit = true, } } pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; if (v.wait_input) |f| { // Asleep until input or until core animation next needs a // frame; 0 is no timeout at all. // A render-time animation draws every loop: the wait only takes // what input is there. const timeout: u32 = if (p.continuous()) 1 else 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)); // What a 9P open wrote in a row goes in once the writes pause. ctlfs.pane.flushPausedBatch(p); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); turn.settle(); // A quitting frame has already freed what it would draw. if (p.quit) return; if (v.poll_frame) |f| f(h.ctx); // 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. // 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 !p.continuous() and (v.now != null or !p.animationActive())) return; // No one to see it: the frame stays owed (needs_frame), not drawn. // A pty streaming into an unwatched session spent most of its time // drawing what nobody looked at. if (v.watched) |f| if (!f(h.ctx)) return; _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); // Settled before post_present: a shell ticks there, and what that // tick moves owes the next frame. p.needs_frame = p.present_skipped; p.present_skipped = false; if (v.post_present) |f| f(h.ctx); } /// One request of the control filesystem, from whichever thread has the /// turn (`pardes.turn`). Not an event: it neither sweeps nor resets /// anything the editor's own step may be in the middle of using, so it /// can be answered while that step is out in a syscall. Only a request /// that changes a pane costs a frame, which is why a round trip on a /// local socket costs microseconds and not a vsync. pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply { if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req); // An address, a limit or the mark flag is nothing drawn: a script's // addr write then its close costs no frame between them. const drawn = if (ctlfs.Node.target(req.node)) |t| !(t == .pane and (t.pane.file == .addr or t.pane.file == .limit or t.pane.file == .mark)) else true; if (drawn) p.needs_frame = true; p.raw_hover_intent = false; look.cancelLookHover(p); p.fs.serving = true; p.fs.lsp_answer_at = null; const reply = ctlfs.handle(p, req); p.fs.serving = false; // The one-failure rule is a write's: a refused open (its OTRUNC's // truncation, a setattr, too), create or remove is its Rerror alone, // no err, as is a write to pane/new, which is only ever read. const ruled = req.op == .write and req.node != @intFromEnum(ctlfs.TopFile.new); if (reply.status == .err and ruled) ctlfs.events.noteError(p, req, reply); // 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 // the end of whatever the editor does next. p.sync(); p.fsReport(); // A /focus write moves the keyboard as a click does: the programs // that asked (DECSET 1004) hear it now, as after update(). p.reportFocus(); ctlfs.events.announce(p); return reply; } pub fn update(p: *Pardes, ev: Event) void { defer p.reportFocus(); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; p.shell_rows.sweep(p.gpa); Messages.expireUnattended(p); // An announcement stands in only until its own step says more. for (p.panes) |slot| if (slot) |pane| { pane.msg_announcement = false; }; switch (ev) { .tick => {}, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { p.raw_hover_intent = false; look.cancelLookHover(p); }, .output => |o| if (look.lookHoverPane(p) == o.pane) { p.raw_hover_intent = false; look.cancelLookHover(p); }, .file_changed => |changed| if (look.lookHoverPane(p) == changed.pane) { p.raw_hover_intent = false; look.cancelLookHover(p); }, .pdf_scroll => |scroll| if (look.lookHoverPane(p) == scroll.pane) { p.raw_hover_intent = false; look.cancelLookHover(p); }, else => { p.raw_hover_intent = false; look.cancelLookHover(p); }, } switch (ev) { .key, .mouse => { if (ev == .key or ev.mouse.kind != .motion) p.input_ns = p.now_ns; for (p.panes) |slot| { if (slot) |pane| Messages.dismissMessage(p, pane); } p.clip_pending = null; }, .tick => { Messages.advanceMessages(p); for (0..p.panes.len) |id| exec.flushReplEnter(p, id, false); }, else => {}, } if (p.fs.listeners != 0) p.fs.origin = switch (ev) { .key => 'K', .mouse => 'M', else => p.fs.origin, }; switch (ev) { .resize => |sz| { p.presentation.snap_once = true; if (sz.row_metrics) |metrics| p.row_metrics = metrics; if (comptime pdf_enabled) { var before: [MAX_PANES]?panes.Pdf.Viewport = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf != null) before[id] = panes.Pdf.paneViewport(p, pane); } p.screen_w = sz.cols; p.screen_h = sz.rows; p.cell_pixels.w = @max(1, sz.cell_pixels.w); p.cell_pixels.h = @max(1, sz.cell_pixels.h); layout.compute(p); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf) |*pv| { if (!std.meta.eql(before[id], panes.Pdf.paneViewport(p, pane))) { panes.Pdf.captureLayoutAnchor(pv); pv.layout_valid = false; pv.search_reveal_pending = pv.search_query.len > 0; } } } } else { p.screen_w = sz.cols; p.screen_h = sz.rows; } p.resize_count += 1; }, .output => |o| { const pane = p.panes[o.pane] orelse return; pane.shell_spoke = true; ctlfs.events.notePtyOutput(p, o.pane, o.bytes); panes.terminal.feedOutput(p, pane, o.bytes); ctlfs.pty.noteMarks(p, o.pane, pane); exec.flushReplEnter(p, o.pane, true); }, .exited => |e| if (p.panes[e.pane]) |pane| { if (pane.command == null) ctlfs.pty.shellExited(p, pane, e.status) else if (!pane.command_done) exec.commandDone(p, e.pane, e.status); }, .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); }, .lsp_resp => |r| p.lspResponse(r.id, r.rows), .pipe_resp => |r| Pipe.pipeResponse(p, r.id, r.success, r.outputs, r.failure), .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes), .key => |key| { p.can_ask = true; defer p.can_ask = false; p.handleKey(key); }, .mouse => |m| { if (m.kind == .release) switch (p.drag) { .border_v, .border_h, .move, .column_move => p.presentation.snap_once = true, else => {}, }; mouse.handleMouse(p, m); }, .pdf_scroll => |scroll| { if (comptime pdf_enabled) { if (scroll.pane < MAX_PANES) { if (p.panes[scroll.pane]) |pane| { if (pane.hasPdf() and p.native_images) _ = panes.Pdf.scrollPane(p, pane, @floatCast(scroll.delta_pixels)); } } } }, .paste => |bytes| edit.applyPaste(p, bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .focus => |focused| p.window_focused = focused, .pointer_leave => { p.pointer_inside = false; p.pointer_body_hit = null; p.pointer_tag_hit = null; }, .tick => { mouse.edgeScroll(p); p.chrome_animation.advance(); p.presentation.advance(); look.advanceLookHover(p); }, } _ = p.scratch.reset(.retain_capacity); p.sync(); p.fsReport(); ctlfs.events.announce(p); } fn fsReport(p: *Pardes) void { if (p.fs.listeners == 0) return; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.fs.readers == 0) continue; const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch continue; const snap = &pane.fs.tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; if (snap.capacity != 0 or snap.items.len != 0) ctlfs.events.noteReplace(p, id, true, snap.items, tag); snap.clearRetainingCapacity(); snap.appendSlice(p.gpa, tag) catch {}; } } // ---- tag + selection text (chord sources) ---- pub fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize { for (p.panes, 0..) |slot, id| if (slot) |sp| { if (sp == pane) return id; }; return null; } /// Cycle only the body modes supported by this pane. pub fn cycleMode(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file == null and !pane.isTerminal()) return; const previous = pane.body.mode; p.active = id; tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); switch (previous) { .tty => { pane.body.mode = .normal; pane.body.normal.clear(); }, .normal => edit.enterInsert(p, &pane.body, .at, 1), .insert => { edit.exitInsert(p, &pane.body); if (pane.isTerminal()) panes.terminal.enterTty(p, id); }, } } /// One mode transition for the terminal tag command and the Ctrl-B chord. pub fn toggleTty(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (!pane.isTerminal()) return; p.active = id; tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); if (pane.body.mode == .tty) { pane.body.mode = .normal; pane.body.normal.clear(); } 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)); } } /// Answers the question pane `id` asks on its notice band, logged as /// `ask `: Del's side (`k` above, `j` below) or /// the REPL to send to (its letter). Null, or a choice not offered, /// takes the question back. A key answers it, or `answer` on the pane's /// ctl; false when the pane asks nothing. /// The choices the question pane `id` asks offers, space-separated /// (`k j`, `a b`), or null when it asks none. pub fn questionChoices(p: *const Pardes, id: usize, buf: []u8) ?[]const u8 { const asked = p.panes[id] orelse return null; switch (asked.prompt) { .del_side => return "k j", // Any path; `-` takes it back. .save => return "", .repl_choice => { const ask = p.repl_ask orelse return null; var w = std.Io.Writer.fixed(buf); for (ask.offered, 0..) |serial, i| if (serial != 0) { if (w.buffered().len > 0) w.writeByte(' ') catch {}; w.writeByte(@intCast('a' + i)) catch {}; }; return w.buffered(); }, else => return null, } } /// Answers the pane's question: a choice it offers, else (null, or a /// key it does not offer) takes it back. The log says which, as /// `answer `, after the `ask` it answers. pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool { const asked = p.panes[id] orelse return false; var offered: [64]u8 = undefined; if (p.questionChoices(id, &offered)) |choices| { const chosen: ?u8 = if (choice) |c| (if (std.mem.indexOfScalar(u8, choices, c) != null and c != ' ') c else null) else null; var rec: [32]u8 = undefined; ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {c}", .{ asked.serial, chosen orelse '-' }) catch ""); } switch (asked.prompt) { .repl_choice => { asked.prompt = .none; const ask = p.repl_ask orelse return true; p.repl_ask = null; defer p.gpa.free(ask.text); const letter = choice orelse return true; if (letter < 'a' or letter > 'z') return true; const serial = ask.offered[letter - 'a']; if (serial == 0) return true; // 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 == letter) { // Offered with no program running: said so, not sent. if (!exec.replLive(p, to)) { _ = exec.replGone(p, id, p.panes[to].?); return true; } exec.sendToRepl(p, id, to, ask.text); return true; }; p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent"); }, .del_side => { asked.prompt = .none; if (choice == 'k') exec.runBuiltin(p, .Del, id, "", "k"); if (choice == 'j') exec.runBuiltin(p, .Del, id, "", "j"); }, else => return false, } return true; } fn handleKey(p: *Pardes, key: Key) void { // A double-click is two clicks with nothing between them. p.last_click = null; 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| { const asked = slot orelse continue; switch (asked.prompt) { // Its letter sends it there; anything else takes it back. .repl_choice => _ = p.answerQuestion(id, if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') null else @intCast(key.cp)), .del_side => _ = p.answerQuestion(id, if (hit(key, config.del_above)) 'k' else if (hit(key, config.del_below)) 'j' else null), else => continue, } return; } const pane = p.panes[p.active] orelse return; p.noteTyped(); // Alt-Down and Alt-Up in a pane's tag expand and collapse it, in // either mode: acme's Tagdown and Tagup (plan9port text.c:776). if (pane.focus == .tag and key.alt and !key.ctrl and (key.cp == Key.down or key.cp == Key.up) and tagline.focusedHeader(p) == null) { pane.tag_expanded = key.cp == Key.down; return; } // An open prompt takes the keys even when it answers for a header. const header = if (pane.prompt != .none) null else tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { p.leader_on = false; p.ctrl_w_pending = false; if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle) return p.toggleTty(p.active); // Shift-Escape is the unconditional way out, even mid-application; // bare Escape only leaves at a shell prompt. if (hit(key, config.tty_toggle_alt)) return exec.runBuiltin(p, .Last, p.active, "", null); if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and exec.takesCommandLine(p, p.active)) return exec.runBuiltin(p, .Last, p.active, "", null); panes.terminal.followOutput(pane); // typing snaps back to live output // Paste stays the window's, the one exception to forwarding a raw // tty's keys: Ctrl-V types the register at the program and // Ctrl-Shift-V asks the desktop for its clipboard first. Forwarded, // they reach an application that reads the clipboard on its own // account instead — a coding agent goes there hunting for an image. // A host that folds Shift into the letter says the same thing. if (key.ctrl and !key.alt and (key.cp == 'v' or key.cp == 'V')) { if (key.shift or key.cp == 'V') return edit.clipRequest(p, p.active, .after); // With something in the register this is a paste. With nothing // 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.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text); } return panes.terminal.forwardKey(p, p.active, key); } if (p.leader_on) return p.leaderKey(key); if (p.ctrl_w_pending) { p.ctrl_w_pending = false; for (config.window_keys) |wk| { if (hit(key, &.{ wk.letter, wk.arrow })) return exec.runBuiltin(p, wk.cmd, p.active, "", null); } return; } // insert mode owns Ctrl-w (delete word backward, helix); the focus // prefix applies outside raw tty mode const t = header orelse if (pane.focus == .tag) &pane.tag else &pane.body; if (hit(key, config.window_prefix) and t.mode != .insert and (header != null or pane.prompt == .none)) { p.ctrl_w_pending = true; return; } // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { const free = p.freeSlot() orelse return p.reportError(p.active, "Tty", error.NoPaneSlots); const nt = p.newShell(free, "") catch return; nt.greet = true; // From a tag, as a 9P new is: acme's t->w == nil. exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell); return; } for (config.jump_keys) |jk| { if (!hit(key, &.{jk.chord})) continue; tagline.exitHeader(p); return exec.runBuiltin(p, jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { tagline.exitHeader(p); const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { _ = layout.splitColumn(p, p.active, p.active, false); } return; } const tty_alt = hit(key, config.tty_toggle_alt); const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt; if (pane.isTerminal() and tty_toggle) { return p.toggleTty(p.active); } if (header == null and pane.prompt == .name) { // Enter or the execute key commits the draft and the keyboard // goes back to the body; Esc drops it. if (hit(key, config.look_key) or hit(key, config.exec_key)) { if (tagline.commitTagName(p, pane)) pane.focus = .body; return; } if (hit(key, config.escape)) { pane.closePrompt(); pane.focus = .body; return; } edit.handleInsert(p, &pane.input, key); return; } if (header == null and pane.prompt != .none) { if (hit(key, config.search_submit) or hit(key, config.escape)) { const submit = hit(key, config.search_submit); const target = p.promptTarget(pane); switch (pane.prompt) { .pipe => if (submit) Pipe.submitPipe(p, p.active), .save => if (submit) exec.submitSave(p, p.active), .search => if (submit) look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| if (target) |answered| normal.applySelRegex(p, pane, answered, "", .select), .none, .del_side, .repl_choice, .name => unreachable, } pane.closePrompt(); if (target) |answered| { answered.mode = .normal; answered.normal.clear(); } 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.mode); return; } 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; const cmd = if (hit(key, config.look_key) and !header_word) config.look_cmd else config.exec_cmd; if (t.what != .body) { // An explicit selection, else the word under the cursor in the // text as it is shown: a tag's prefix is part of what a word is. const shown = p.scratch.allocator().dupe(u8, edit.flatSurface(p, t) catch return) catch return; const range = t.primaryRange(shown, 0); const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); const txt = if ((t.vsel.active and t.vsel.explicit) or t.msel.active) shown[lo..hi] else word: { const b = config.wordBounds(shown, modal.selectionCursor(shown, range)); break :word shown[b.lo..b.hi]; }; // A column tag's command acts on that column, empty or not. p.exec_column = if (header != null) p.header_column else null; defer p.exec_column = null; p.exec_header = header != null; defer p.exec_header = false; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. if (header != null) tagline.exitHeader(p) else pane.focus = .body; if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt); return; } edit.pinPaneCursor(p, &pane.body); 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) { if (exec.chordEachSel(p, pane, cmd)) return; if (edit.currentSelText(p, pane)) |txt| { pane.body.vsel.active = false; pane.body.msel.active = false; pane.body.select = false; // 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.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); 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 }; 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 = 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) 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 { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); if (c == config.leader_help) { panes.Output.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| p.reportError(p.active, "help", err); return; } if (p.leader_n >= p.leader_keys.len) return; p.leader_keys[p.leader_n] = c; p.leader_n += 1; const pfx = p.leader_keys[0..p.leader_n]; // a path-less builtin is a row here too (it is the same index Help // reads), and SPC cannot reach one — so both scans skip it for (builtin_rows) |row| { if (std.mem.eql(u8, row.path orelse continue, pfx)) return exec.runBuiltin(p, row.cmd, p.active, "", null); } for (builtin_rows) |row| { if (std.mem.startsWith(u8, row.path orelse continue, pfx)) { p.leader_on = true; return; } } } // ---- helix-modal normal mode ---- // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- /// Where pardes started: the directory of what runs from a header. pub fn sessionDir(p: *Pardes) []const u8 { if (p.session_dir_len) |n| return p.session_dir[0..n]; if (comptime !hosted) return "/"; if (std.c.getcwd(&p.session_dir, p.session_dir.len) == null) return "/"; const n: u16 = @intCast(std.mem.indexOfScalar(u8, &p.session_dir, 0) orelse return "/"); p.session_dir_len = n; return p.session_dir[0..n]; } /// The directory a word run from pane `id`'s text or tag runs in, or, /// from a header, the session's. pub fn execDir(p: *Pardes, id: usize) []const u8 { if (p.exec_header) return p.sessionDir(); return paneDir(p.panes[id] orelse return p.sessionDir()); } pub fn paneDir(pane: *const Pane) []const u8 { switch (pane.cwd) { .inherited => |src| return paneDir(src), .owned => |cwd| return cwd, .none => {}, } if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fs.path.dirname(pv.path) orelse "/"; if (pane.image) |iv| return std.fs.path.dirname(iv.path) orelse "/"; return pane.cwdSlice(); } /// Open a prompt on `pane`'s notice band answering for `t`: the body, /// the pane's tag, or the header that has the keyboard. pub fn startPrompt(p: *Pardes, pane: *Pane, t: *Text, request: union(enum) { search: []const u8, pipe: modal.Normal.PipeBehavior, save, }) void { // Nobody is at the keyboard to answer a command a script wrote to a // ctl file: the write fails instead (src/ninep/ctl.zig). if (p.fs.no_prompt) { p.fs.refused = true; // A failure like any other: the builtin's announcement goes. p.fs.failures +%= 1; return; } // Asked for over 9P (Save from an exec on a terminal): a question // the log says, `ask save path`, answered on the pane's // ctl with `answer ` or `answer -`; asked again, the one // already open stands, not a second on top of it. if (request == .save and p.fs.serving) { if (pane.prompt == .save) return; var rec: [32]u8 = undefined; ctlfs.events.notePath(p, "ask", std.fmt.bufPrint(&rec, "{d} save path", .{pane.serial}) catch ""); } const marker = switch (request) { .search => |marker| marker, .save => config.save_marker, .pipe => |how| pipe: { // A body is piped only when it is a file that saves; a tag's // or a header's text is its own. if (t.what == .body) { const file = pane.file orelse return; if (!panes.Output.fileTraits(file.output).saves) return; } break :pipe Pipe.pipeMarker(how); }, }; // Save asks for a path, so its answer starts out as the pane's own // directory for the name to be typed onto. const dir = if (request == .save) paneDir(pane) else ""; const slash: []const u8 = if (request == .save and (dir.len == 0 or dir[dir.len - 1] != '/')) "/" else ""; const answer = std.mem.concat(p.gpa, u8, &.{ marker, dir, slash }) catch return; // The keyboard comes back to the text answered for; a prompt for // the body takes it out of a tag or header. switch (t.what) { .body => { pane.focus = .body; if (p.panes[p.active] == pane) tagline.exitHeader(p); }, .tag => pane.focus = .tag, .column, .workspace, .input => {}, } // Not closePrompt: an s/S snapshot was taken for this very prompt. if (pane.input.own) |old| p.gpa.free(old); pane.prompt = switch (request) { .search => .search, .pipe => |how| .{ .pipe = how }, .save => .save, }; pane.prompt_for = t.what; pane.input = .{ .what = .input, .own = answer, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(answer.len) }; // Whatever mode the text was in, it is in normal mode once the // prompt is answered or dropped. t.mode = .normal; t.normal.clear(); } /// The text `pane`'s open prompt answers for, if it is still there. pub fn promptTarget(p: *Pardes, pane: *Pane) ?*Text { return switch (pane.prompt_for) { .body => &pane.body, .tag => &pane.tag, .column, .workspace => if (tagline.focusedHeader(p)) |h| (if (h.what == pane.prompt_for) h else null) else null, .input => null, }; } pub const SavePath = Effect.Buf(effect_path_cap); pub fn lspRequest(p: *Pardes, asked: usize, kind: lsp.Kind, arg: []const u8) void { if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary // Lspwhy narrates the last query where it was asked -- that pane's // file, place and argument -- from wherever it is run. const last_pane: ?usize = if (kind == .explain) (if (p.lsp_last) |last| p.paneBySerial(last.serial) else null) else null; const id = last_pane orelse asked; var said: [128]u8 = undefined; if (!lsp.supports.contains(kind)) return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: no language server here answers it", .{lspWord(kind)}) catch "no language server answers it"); const pane = p.panes[id] orelse return; if (kind != .status) { const f = pane.file orelse return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: only a file's pane asks a language server", .{lspWord(kind)}) catch "only a file's pane asks"); if (f.output != null) return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: only a file's pane asks a language server", .{lspWord(kind)}) catch "only a file's pane asks"); } if (arg.len > 128) return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: its argument is at most 128 bytes", .{lspWord(kind)}) catch "argument too long"); if (kind == .rename) { const zig_buf = if (pane.file) |f| std.mem.endsWith(u8, f.path, ".zig") else true; if ((zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) or (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null)) return p.reportFailure(id, std.fmt.bufPrint(&said, "Rename: {s} is no name", .{arg}) catch "Rename: no name"); } const off = if (pane.file) |f| modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)), }) else 0; // Lspwhy asks the last query again, narrated: its kind and argument, // at its place when asked from its pane. None yet: a definition. var explained: [160]u8 = undefined; var sent = arg; var at = off; if (kind == .explain) { if (p.lsp_last) |last| { sent = std.fmt.bufPrint(&explained, "{s} {s}", .{ @tagName(last.kind), last.arg.slice() }) catch ""; if (last_pane != null) at = last.offset; } } else if (kind != .status) p.lsp_last = .{ .kind = kind, .arg = .from(arg), .serial = pane.serial, .offset = @intCast(off) }; p.lsp_seq +%= 1; // A 9P write that asked it is answered once the answer is in. if (p.fs.serving) p.fs.lsp_answer_at = turn.lsp_answers; p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id, .serial = pane.serial, .revision = if (pane.file) |f| f.revision else 0, .arg = .from(arg), .row = pane.body.cur_row, .col = pane.body.cur_col, }; p.emit(.{ .lsp = .{ .id = p.lsp_seq, .kind = kind, .pane = @intCast(id), .offset = @intCast(at), .arg = .from(sent), } }); } const LspEdit = struct { start: usize, end: usize, text: []const u8 }; fn parseLspEdits(p: *Pardes, bytes: []const u8, fallback: ?[]const u8) ?[]LspEdit { if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null; const a = p.scratch.allocator(); const edits = a.alloc(LspEdit, std.mem.count(u8, bytes, "\n")) catch return null; var lines = std.mem.splitScalar(u8, bytes, '\n'); var n: usize = 0; var form: enum { undecided, edit, put } = .undecided; while (lines.next()) |line| { if (line.len == 0) { if (lines.peek() == null) break; return null; } var fields = std.mem.tokenizeScalar(u8, line, ' '); const head = fields.next() orelse return null; const want: @TypeOf(form) = if (std.mem.eql(u8, head, "@put")) .put else if (std.mem.eql(u8, head, "@edit")) .edit else return null; if (form == .undecided) form = want; if (form != want) return null; const start = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null; const end = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null; var text: []const u8 = if (want == .edit) (fallback orelse return null) else ""; if (want == .put) { if (fields.next()) |encoded| text = decodePct(a, encoded) orelse return null; } if (fields.next() != null) return null; edits[n] = .{ .start = start, .end = end, .text = text }; n += 1; } return if (n == 0) null else edits[0..n]; } /// The `@put` text codec's read half (lsp.put writes it): `%XX` bytes, /// everything else literal. An invalid escape fails the whole record. fn decodePct(a: std.mem.Allocator, encoded: []const u8) ?[]const u8 { const out = a.alloc(u8, encoded.len) catch return null; var n: usize = 0; var i: usize = 0; while (i < encoded.len) { if (encoded[i] == '%') { if (i + 3 > encoded.len) return null; out[n] = std.fmt.parseInt(u8, encoded[i + 1 .. i + 3], 16) catch return null; i += 3; } else { out[n] = encoded[i]; i += 1; } n += 1; } return out[0..n]; } fn mapLspEditOffset(edits: []const LspEdit, old: usize) usize { var old_at: usize = 0; var new_at: usize = 0; for (edits) |e| { if (old < e.start) return new_at + (old - old_at); new_at += e.start - old_at; if (old < e.end) return new_at + @min(old - e.start, e.text.len -| 1); new_at += e.text.len; if (old == e.end) return new_at; old_at = e.end; } return new_at + (old - old_at); } fn applyLspEdits(p: *Pardes, pane: *Pane, revision: u32, edits: []const LspEdit) bool { const f = if (pane.file) |*file| file else return false; if (f.revision != revision) return false; var removed: usize = 0; var added: usize = 0; var previous_end: usize = 0; var same = true; for (edits) |e| { if (e.start < previous_end or e.start > e.end or e.end > f.content.len) return false; if (e.start == e.end and e.text.len == 0) return false; // an empty insert is a malformed record removed = std.math.add(usize, removed, e.end - e.start) catch return false; added = std.math.add(usize, added, e.text.len) catch return false; previous_end = e.end; if (same and !std.mem.eql(u8, f.content[e.start..e.end], e.text)) same = false; } if (same) return false; // a no-op response earns no undo entry const final_len = std.math.add(usize, f.content.len - removed, added) catch return false; const replacement = p.gpa.alloc(u8, final_len) catch return false; const old_cursor = modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)), }); const mapped_cursor = mapLspEditOffset(edits, old_cursor); var read_at: usize = 0; var write_at: usize = 0; for (edits) |e| { @memcpy(replacement[write_at .. write_at + (e.start - read_at)], f.content[read_at..e.start]); write_at += e.start - read_at; @memcpy(replacement[write_at .. write_at + e.text.len], e.text); write_at += e.text.len; read_at = e.end; } @memcpy(replacement[write_at..], f.content[read_at..]); edit.pushUndo(p, &pane.body); panes.File.setContent(p, f, replacement); const cursor = modal.positionAt(f.content, mapped_cursor); pane.body.cur_row = @intCast(cursor.row); pane.body.cur_col = @intCast(cursor.col); pane.body.vsel.active = false; pane.body.msel.active = false; pane.body.select = false; pane.body.sticky_col = -1; pane.ensureCursorVisible(); return true; } /// The builtin word that asks `kind`, for what it says. fn lspWord(kind: lsp.Kind) []const u8 { return switch (kind) { .hover => "Hover", .explain => "Lspwhy", .incoming_calls => "Callers", .outgoing_calls => "Callees", .document_symbols => "Symbols", .workspace_symbols => "WsSymbols", .diagnostics => "Diagnostics", .workspace_diagnostics => "WsDiagnostics", .code_action => "CodeAction", .select_refs => "SelectRefs", .supertypes => "Supertypes", .subtypes => "Subtypes", .rename => "Rename", .format => "Format", else => @tagName(kind), }; } /// What was said of a failure just now on pane `id` is also the waiting /// 9P write's (9p_io, `fs.late_failure`). pub fn noteLateFailure(p: *Pardes, id: usize) void { // Said by reportFailure just now: its words fitted whole, the // reason kept; else what the message row has. const said = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0) p.fs.said[0..p.fs.said_len] else if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else return; const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); @memcpy(p.fs.late_failure[0..kept.len], kept); p.fs.late_failure_len = @intCast(kept.len); } pub fn lspResponse(p: *Pardes, id: u32, response: ?[]const u8) void { defer turn.lspAnswered(); const w = p.lsp_wait orelse return; if (w.id != id) return; // superseded by a newer press, or the pane died p.lsp_wait = null; const pane = p.panes[w.pane] orelse return; if (pane.serial != w.serial) return; // Whatever fails from here -- no answer, nothing found, no room for // its pane -- fails a 9P write that asked it too. const failures = p.fs.failures; defer if (p.fs.failures != failures) p.noteLateFailure(w.pane); var said: [256]u8 = undefined; const rows = response orelse return p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: the language server did not answer", .{lspWord(w.kind)}) catch "no answer"); // A backend that cannot answer it says why (`@none `). if (std.mem.startsWith(u8, rows, "@none ")) { const why = std.mem.trimEnd(u8, rows["@none ".len..], "\n"); return p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: {s}", .{ lspWord(w.kind), why[0..@min(why.len, 200)] }) catch "no answer"); } if (w.kind == .rename or w.kind == .format) { const fallback: ?[]const u8 = if (w.kind == .rename) w.arg.slice() else null; if (p.parseLspEdits(rows, fallback)) |edits| { if (p.applyLspEdits(pane, w.revision, edits)) { var buf: [64]u8 = undefined; const verb: []const u8 = if (w.kind == .rename) "renamed" else "formatted"; p.setMessage(w.pane, std.fmt.bufPrint(&buf, "{s} {d} range(s)", .{ verb, edits.len }) catch verb); } return; } if (w.kind == .rename and rows.len > 0) { // fall through: a multi-file rename previews as rows } else if (w.kind == .format and rows.len > 0) { // fall through: "cannot format" and friends are prose } else if (w.kind == .format) return; // a rename of nothing says so, below } if (rows.len == 0) { if (w.kind == .completion and pane.body.mode == .insert and pane.body.nsel == 0 and pane.body.cur_row == w.row and pane.body.cur_col == w.col) edit.insertTab(p, &pane.body); // Nothing found fails, but for a file with no diagnostics, // which is no failure, and a completion's Tab. const none: ?[]const u8 = switch (w.kind) { .completion, .format => null, .rename => "nothing here to rename", .diagnostics, .workspace_diagnostics => return p.setMessage(w.pane, std.fmt.bufPrint(&said, "{s}: none", .{lspWord(w.kind)}) catch "none"), .incoming_calls => "callers not found", .outgoing_calls => "callees not found", .hover => "nothing to show: not found", else => "not found", }; if (none) |text| p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: {s}", .{ lspWord(w.kind), text }) catch "nothing found"); return; } const from: panes.Output.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); if (panes.Output.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); const place = ln[0..panes.Output.location(ln).end]; p.look_missed = false; look.lookAt(p, w.pane, place); // A word that asks for a list (Callers, Refs) with one answer // jumps to it with no list: where it went is said, as the list // would have shown it. A definition's jump is the answer itself. const listing = switch (w.kind) { .references, .incoming_calls, .outgoing_calls, .supertypes, .subtypes => true, else => false, }; if (listing and !p.look_missed) { var where: [300]u8 = undefined; p.setMessage(p.active, std.fmt.bufPrint(&where, "{s}: the one, {s}", .{ lspWord(w.kind), place[0..@min(place.len, 240)] }) catch lspWord(w.kind)); } return; } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const content = p.gpa.dupe(u8, rows) catch return; panes.Output.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| { p.reportError(w.pane, "language response", err); return; }; // What a 9P write that asked it answers: the pane the rows are in // (a multi-file Rename's preview), not the pane it was asked from. if (pane.search_pane) |rid| if (p.panes[rid]) |rp| { p.fs.lsp_result = rp.serial; }; if (w.kind == .rename) { var said_buf: [128]u8 = undefined; // Rows that only name a file the server left alone (lsp_client // renameEdits) are no edits. const unrenamed = std.mem.count(u8, rows, ", which the server did not rename\n"); p.setMessage(w.pane, if (unrenamed > 0) std.fmt.bufPrint(&said_buf, "Rename: previewed, not applied: the server renamed in this file only, and {d} other open file(s) say the name", .{unrenamed}) catch "Rename: previewed" else std.fmt.bufPrint(&said_buf, "Rename: {d} edit(s) across files, previewed, not applied", .{nrows}) catch "Rename: previewed"); } if (panes.Output.traits(from).jumps) { const result = p.panes[pane.search_pane orelse return] orelse return; const file = pane.file orelse return; const row = panes.Output.nextResult(result.file.?.content, result.file.?.location_rows, lsp.rel(dir, file.path), .{ .line = @as(usize, @intCast(@max(0, w.row))) + 1, .col = @as(usize, @intCast(@max(0, w.col))) + 1, }); _ = look.jumpResult(p, w.pane, row); } } // ---- n/N: the walk over look-able text ---- pub const max_look_rows = 8192; // ---- edit operations (d / c / y / p) ---- // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- 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| { p.hover_col = mapped.col; p.hover_row = mapped.row; mouse.dragUpdate(p, mapped.col, mapped.row, p.pointer_body_hit); } } look.refreshLookHoverFromRaw(p); } pub fn abandonPanelAnimations(p: *Pardes) void { p.presentation.cancel(); look.cancelLookHover(p); } /// A native post pass proved unavailable: the chain is emptied, which /// DumpConfig then reports. pub fn disableSceneEffects(p: *Pardes) void { p.settings.post.clear(); } // ---- doc panes ---- pub fn openTutorView(p: *Pardes, id: usize) !*Pane { const content = try p.gpa.dupe(u8, tutor_text); errdefer p.gpa.free(content); // A generated pane, named as every one is: `+` in the session's // directory, never a bare path Save could write at the root. const path = try std.fs.path.join(p.gpa, &.{ p.sessionDir(), "+Tutor" }); errdefer p.gpa.free(path); const history = try panes.File.History.create(p.gpa); errdefer p.gpa.destroy(history); const pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content, .history = history }; pane.body.cur_pinned = true; return pane; } pub fn setTestFile(p: *Pardes, content: []const u8) !*Pane { const copy = try p.gpa.dupe(u8, content); errdefer p.gpa.free(copy); const path = try p.gpa.dupe(u8, "/test.txt"); errdefer p.gpa.free(path); const history = try panes.File.History.create(p.gpa); errdefer p.gpa.destroy(history); const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h); errdefer p.gpa.destroy(pane); try p.deinitPane(p.panes[0].?); p.panes[0] = null; pane.file = .{ .path = path, .content = copy, .history = history }; p.installPane(0, pane); pane.body.cur_pinned = true; p.active = 0; p.sync(); return pane; } // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- // ---- 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. Focus on it follows its rows to the /// pane that grew into them. A column outlives its panes, as acme's /// does (cols.c:186): closing its last one leaves it empty, the keyboard /// on its tag; only Delcol takes a column away. The session's last pane /// closing ends the session. 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; const place = layout.findPane(p, id); const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null; // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); const gainer = layout.absorbVWeight(p, id, to); const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null; layout.removePane(p, id); p.panes[id] = null; if (p.active != id) return; p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty(); if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c); } /// Delcol: close column `col` and every pane in it. pub fn removeColumn(p: *Pardes, col: usize) !void { if (col >= p.ncol) return; const had_focus = p.keyboardColumn(p.active) == col; const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; const count = p.col_n[col]; for (0..count) |i| { ids[i] = p.col_panes[col][i]; parents[i] = p.panes[ids[i]].?; } try p.detachCwds(parents[0..count]); for (ids[0..count], parents[0..count]) |closed, pane| { p.retirePane(pane); layout.removePane(p, closed); p.panes[closed] = null; } layout.dropColumn(p, col); // The last column gone, the window stays, empty but for the // workspace tag, as acme's does; Newcol starts again from there. if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return tagline.enterHeader(p, null); } /// No pane is left: the session is over. fn quitEmpty(p: *Pardes) void { p.quit = true; p.emit(.quit); } /// The pane Last should fall back to when the jumplist has nothing: the /// one below in this column, wrapping to the top, and any other live pane /// failing that. A pane that opened beside this one and was never focused /// -- the text pane the bare tty layout puts under the shell -- is not on /// the jumplist, and alternating with it is exactly what Last is for. pub fn neighbourPane(p: *Pardes, from: usize) ?usize { if (layout.findPane(p, from)) |at| { const n = p.col_n[at.col]; if (n > 1) return p.col_panes[at.col][(at.idx + 1) % n]; } for (p.panes, 0..) |slot, id| { if (slot != null and id != from) return id; } return null; } /// The pane that takes over the keyboard from column `col`: in the /// column layout.dropColumn gives the width to, the one level with the /// middle of `r` (of the screen without one). Asked while rects hold. fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?usize { if (p.ncol < 2) return null; const next = if (col > 0) col - 1 else col + 1; const mid = if (r) |at| at.y + at.h / 2 else p.screen_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. pub fn anyPane(p: *const Pardes) ?usize { for (p.panes, 0..) |slot, k| if (slot != null) return k; return null; } fn samePlace(p: *const Pardes, a: Loc, b: Loc) bool { if (a.pane != b.pane or a.serial != b.serial) return false; if (a.line == 0 or b.line == 0) return true; const body = @max(1, p.rects[b.pane].h -| BOX_H); return @max(a.line, b.line) - @min(a.line, b.line) <= body; } fn trackJump(p: *Pardes) void { var w: usize = 0; var cur: usize = 0; for (p.jumps[0..p.njumps], 0..) |j, i| { if (i == p.jcur) cur = w; // survives -> lands at w; dies -> the next one does var kept = j; const live = if (j.serial == 0) false else if (p.panes[j.pane]) |pane| pane.serial == j.serial else false; // A file's pane closed: its entry stays, closed, while the recent // list knows the file, and a jump to it opens it again. if (!live) { if (j.path_hash == 0 or p.recentByHash(j.path_hash) == null) continue; kept.serial = 0; // Open again in some pane (a look, Recent): the entry is that // pane's, and closed only while no pane has the file. for (p.panes, 0..) |slot, id| if (slot) |pane| if (recentDot(pane)) |d| { if (std.hash.Wyhash.hash(0, d.path) != j.path_hash) continue; kept.pane = @intCast(id); kept.serial = pane.serial; break; }; } // Two in a row the same (a closed one reopened beside its own, // or the entry between them gone): one entry, one step. if (w > 0 and std.meta.eql(p.jumps[w - 1], kept)) { if (i == p.jcur) cur = w - 1; continue; } p.jumps[w] = kept; w += 1; } p.njumps = w; p.jcur = @min(cur, w -| 1); const pane = p.panes[p.active] orelse return; const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, .line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.body.cur_pinned) @intCast(@max(0, pane.body.cur_row) + 1) else 0, .col = if (pane.pdfPage() != null) 0 else if (pane.body.cur_pinned) @intCast(@max(0, pane.body.cur_col) + 1) else 0, .path_hash = if (recentDot(pane)) |d| std.hash.Wyhash.hash(0, d.path) else 0, }; if (p.njumps > 0 and p.samePlace(p.jumps[p.jcur], now)) { p.jumps[p.jcur] = now; // same visit; the entry IS where you are return; } // a new jump made from the middle of the list drops everything ahead of // it, the way vim's does: the future you did not take is not history. if (p.njumps > 0) p.njumps = p.jcur + 1; if (p.njumps >= 2 and p.samePlace(p.jumps[p.njumps - 2], now)) { p.jumps[p.njumps - 2] = p.jumps[p.njumps - 1]; p.jumps[p.njumps - 1] = now; p.jcur = p.njumps - 1; return; } if (p.njumps == MAX_JUMPS) { std.mem.copyForwards(Loc, p.jumps[0 .. MAX_JUMPS - 1], p.jumps[1..]); p.njumps -= 1; } p.jumps[p.njumps] = now; p.jcur = p.njumps; p.njumps += 1; } pub fn jumpBy(p: *Pardes, delta: i32) void { const next = @as(i64, @intCast(p.jcur)) + delta; // At an end of the list: said, not a step that silently does nothing. if (p.njumps == 0 or next < 0 or next >= p.njumps) return p.setMessage(p.active, if (delta < 0) "Back: nothing further back" else "Forward: nothing further on"); const j = p.jumps[@intCast(next)]; if (j.pane >= MAX_PANES) return; // A closed file's entry: opened again at its dot, placed as a look // places it, and the entry is that pane from here on. if (j.serial == 0) { const e = p.recentByHash(j.path_hash) orelse return; // Deleted since: its entries go, it is said, and the step goes // on to the next place, never stuck on one that cannot open. if (!recentKeeps(e.path)) { var said: [recent_path_cap + 32]u8 = undefined; p.setMessage(p.active, std.fmt.bufPrint(&said, "{s}: deleted, skipped", .{e.path}) catch "deleted, skipped"); const hash = j.path_hash; var w: usize = 0; var cur = p.jcur; for (p.jumps[0..p.njumps], 0..) |k, i| { if (k.serial == 0 and k.path_hash == hash) { if (i < p.jcur) cur -= 1; continue; } p.jumps[w] = k; w += 1; } p.njumps = w; p.jcur = @min(cur, w -| 1); // The same step again, over the list without them: each // round takes one file's entries away, so it ends. return p.jumpBy(delta); } const from = p.active; look.reopenAt(p, from, e.path, .{ .line = @max(1, j.line), .col = @max(1, j.col) }); const opened = p.panes[p.active] orelse return; const f = opened.file orelse return; if (!std.mem.eql(u8, f.path, e.path)) return; // it did not open // Every entry of that file is the reopened pane from here on, so // a step onto another of them moves in it rather than reopening. for (p.jumps[0..p.njumps]) |*k| if (k.serial == 0 and k.path_hash == j.path_hash) { k.pane = @intCast(p.active); k.serial = opened.serial; }; p.jcur = @intCast(next); return; } const pane = p.panes[j.pane] orelse return; if (pane.serial != j.serial) return; if (p.panes[p.active]) |source| look.clearNavigationSelection(p, source); if (p.active != j.pane) look.clearNavigationSelection(p, pane); p.jcur = @intCast(next); look.focusPaneLine(p, j.pane, .{ .line = j.line, .col = j.col }, .center); } /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. /// The rows pane `id`'s tag takes at its width, wrapped as `sync` wraps /// it: what a placement must leave it above its body rows. pub fn wantedTagRows(p: *Pardes, id: usize) u16 { const pane = p.panes[id] orelse return BOX_H; if (pane.collapsed or !pane.tag_expanded) return BOX_H; if (p.rects[id].w == 0) return pane.tag_rows; // never laid out var wrap_room: [32 * 1024]u8 = undefined; var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room); const wa = wrap_fba.allocator(); const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows; const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows; const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows; return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS))); } pub fn sync(p: *Pardes) void { var refused = false; while (p.unplaced.findFirstSet()) |id| { p.unplaced.unset(id); if (p.panes[id]) |pane| { // Refused, it was never there: the serial it took is the // next pane's, as a refused Newcol's column serial is. const newest = pane.serial == p.next_serial; p.removePane(id, null) catch {}; if (newest and p.panes[id] == null) p.next_serial -= 1; } refused = true; } // A refused pane gone, no pane it squeezed stays under its minimum. if (refused) for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { _ = layout.shareColumn(p, c); }; // A file opened or closed this step: the kept list follows. p.saveRecent(); tagline.flattenHeaders(p); if (p.header_column != null and p.columnBarHeight() == 0) { tagline.exitHeader(p); } p.reapPanes(); layout.compute(p); p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; // The keyboard is on one pane, or on a header: a tag it has left // hands it back to its body, and a name drafted there is dropped. if (id != p.active or p.header_focus) { pane.focus = .body; if (pane.prompt == .name) pane.closePrompt(); } // A sweep over the tag is the tag's while it has the keyboard. const swept = pane.sel[sel_slot]; if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < pane.tag_rows) { pane.clearPointerSelection(sel_slot); pane.sel[sel_slot].state = .none; } if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; while (off < state.reply_len) { const n = @min(state.reply_len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(state.reply[off .. off + n]) } }); off += n; } state.reply_len = 0; }; const r = p.rects[id]; // The tag takes a row per line, leaving the body one row if it // had any; a folded pane shows its first line only. // Measured on the stack: this runs on every event, and not // every event may take from the scratch arena. var room: [2 * limits.host_path_cap + 256]u8 = undefined; var measure = std.heap.FixedBufferAllocator.init(&room); const lead = if (tagline.prefixIn(measure.allocator(), pane)) |prefix| prefix.len else |_| pane.tag_lead; if (lead != pane.tag_lead) { const t = &pane.tag; const old: i32 = @intCast(pane.tag_lead); const new: i32 = @intCast(lead); if (t.cur_row == 0 and t.cur_col >= old) t.cur_col += new - old; if (t.vsel.row == 0 and t.vsel.col >= old) t.vsel.col += new - old; for (t.sels[0..t.nsel]) |*sel| { if (sel.row == 0 and sel.col >= old) sel.col += new - old; if (sel.arow == 0 and sel.acol >= old) sel.acol += new - old; } pane.tag_lead = lead; } // A row per shown row: the tag's lines wrapped at its width. var wrap_room: [32 * 1024]u8 = undefined; var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room); const wa = wrap_fba.allocator(); var lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1; var wanted = lines; var caret_row: usize = @intCast(@max(0, pane.tag.cur_row)); if (tagline.prefixIn(wa, pane)) |prefix| { if (std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) })) |whole| { if (tagline.wrapTag(wa, whole, tagline.tagWidth(p, id))) |shown| { lines = shown.rows(); // Collapsed, one row, as acme's Tagup sets taglines to 1 // (plan9port text.c:786). wanted = if (pane.tag_expanded) lines else 1; const cur = modal.offsetAt(whole, .{ .row = caret_row, .col = @intCast(@max(0, pane.tag.cur_col)) }); caret_row = std.mem.count(u8, shown.text[0..@min(shown.fromReal(cur), shown.text.len)], "\n"); } else |_| {} } else |_| {} } else |_| {} if (!pane.tag_expanded) wanted = 1; pane.tag_rows = @intCast(@min(wanted, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); if (pane.collapsed) pane.tag_rows = BOX_H; if (pane.focus == .tag and pane.prompt != .name) { const cur: usize = caret_row; var top: usize = pane.tag_top; if (cur < top) top = cur; if (cur >= top + pane.tag_rows) top = cur + 1 - pane.tag_rows; pane.tag_top = @intCast(@min(top, lines - pane.tag_rows)); } else pane.tag_top = 0; const cols = @max(1, r.w -| config.GUTTER); // The tag steals the top rows. The pty is what the pane shows // (layout keeps a terminal two body rows where the window has // them): a taller pty would put its cursor below the pane. const rows = @max(1, r.h -| pane.tag_rows); // A terminal's pty follows its pane down to a row, whatever the // resize; only a folded one keeps the size it had, and one not // placed yet the grid it was made with. if ((cols != pane.cols or rows != pane.rows) and (r.h > pane.tag_rows or (pane.isTerminal() and !pane.collapsed and r.h > 0))) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { panes.terminal.resizeGrid(pane, p.gpa, cols, rows); p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); pane.fs.winsize = null; } pane.cols = cols; pane.rows = rows; if (pane.file) |*file| file.syntax_dirty = true; } panes.terminal.releasePendingCommandIfReady(p, id, pane); // The greeting `ls` of a new terminal in a directory is typed at // its shell's FIRST prompt, or never: typed later it would land // in whatever runs then, or in the middle of a line someone // typed (panes.terminal.greeting). if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.terminal.greeting(pane)) { .wait => {}, .never => pane.greet = false, .now => { p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } }); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } }); pane.greet = false; }, }; } } pub fn theme(p: *const Pardes) *const Theme { if (p.custom_theme) |*custom| return custom; return &themes[p.settings.theme]; } /// Displayed colors for anchored UI chrome. Unlike `theme()`, this may be /// between themes while a live transition is active. pub fn chromeTheme(p: *const Pardes) *const ChromeTheme { return &p.chrome_animation.displayed; } /// The theme's own chrome, not a fade's (`theme()`'s, worked out once /// per theme: its contrast searches take pow calls, too many for a /// grapheme or a row). pub fn bodyChrome(p: *Pardes) *const ChromeTheme { const th = p.theme(); if (p.body_chrome) |*cached| if (std.meta.eql(cached.theme, th.*)) return &cached.chrome; p.body_chrome = .{ .theme = th.*, .chrome = ChromeTheme.fromTheme(th) }; return &p.body_chrome.?.chrome; } /// 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; // Held 9P writes go in when they pause: wake for it, never before // the next frame (writes served before the shell's clock was first // seen are stamped 0, which `advance` would count back from). if (p.fs.batch.bytes.items.len > 0) return @max(next, p.fs.batch.last_ns + ctlfs.pane.batch_pause_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| { const wait = Messages.messageWake(p, pane) orelse continue; frames = if (frames) |soonest| @min(soonest, wait) else wait; }; // A paste's Enter waiting on its REPL goes at the end of its wait, // a time on the clock: the frames from `stepped_ns` that reach it. for (p.panes) |slot| if (slot) |pane| if (pane.repl_enter > 0) { const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns + animation.frame_ns - 1) / animation.frame_ns); frames = if (frames) |soonest| @min(soonest, wait) else wait; }; return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null; } /// A render-time animation is moving (a pane's lift): it is sampled at /// each frame's own `now`, so while it moves the shell draws every loop /// and its present (vsync) paces it, at the display's rate. `nextWake` /// is for the stepped animations, which count frames of `frame_ns`. pub fn continuous(p: *const Pardes) bool { for (p.lifts) |spring| if (!spring.settled) return true; return p.cursor_glide.moving() or p.shell_continuous; } /// 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 or p.continuous(); } /// 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; p.haptic = pulse; } /// Take the armed pulse and disarm. The shell calls this once per pump, /// after the tick that may have set it. pub fn takeHaptic(p: *Pardes) Haptic { if (comptime platform != .macos) return .none; defer p.haptic = .none; return p.haptic; } pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit { for (p.surface.tagLayers()) |*layer| { 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; 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; } /// The pane's first body row: under the tag when it is on top, over it /// when it is at the bottom. Notice bands do NOT move it -- they are an /// overlay, drawn over the top rows rather than taking them. pub fn bodyTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { return if (p.settings.tag_bottom) r.y else r.y + pane.tag_rows; } /// The tag's first row: the pane's top, or its last rows with Tagbottom. pub fn tagTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { return if (p.settings.tag_bottom) r.y + r.h -| pane.tag_rows else r.y; } pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { 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.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 (draw.zig) ---- pub const render = draw.render; pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool { const s = &p.surface; if (s.nimages == s.images.len) { const old_len = s.images.len; const new_len = @max(@as(usize, 4), std.math.mul(usize, @max(old_len, 1), 2) catch return false); const grown = if (old_len == 0) p.gpa.alloc(?ImagePlace, new_len) catch return false else p.gpa.realloc(s.images, new_len) catch return false; @memset(grown[old_len..], null); s.images = grown; } s.images[s.nimages] = place; s.nimages += 1; return true; } }; test "Esc alternates between two panes of the SAME kind" { if (platform == .web) return; const gpa = std.testing.allocator; var cwdbuf: [4096]u8 = undefined; const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return))); var pathbuf: [4096]u8 = undefined; const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd}); const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 40, .file = boot }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 40 } }); const a = p.active; exec.runBuiltin(p, .Look, a, "", "build.zig"); p.sync(); const b = p.active; try std.testing.expect(b != a); try std.testing.expect(!p.panes[a].?.isTerminal() and !p.panes[b].?.isTerminal()); for (0..6) |_| { const was = p.active; p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expectEqual(if (was == a) b else a, p.active); } } test "a boot file that will not open boots an errors pane rather than failing the launch" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "/definitely/not/here/notes.md", }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const pane = p.panes[0].?; const f = pane.file.?; try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // The reason IN WORDS, not an error name: `PermissionDenied` on a screen // is jargon, and the whole point of this pane is that a human reads it. try std.testing.expect(std.mem.indexOf(u8, f.content, "cannot open") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "no file of that name") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "/definitely/not/here/notes.md") != null); } test "argv naming nothing boots an errors pane rather than failing the launch" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .missing = .{ .word = "notes/tdoo.md", .dir = "/home/pardes-test/work" }, }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); // One pane, filling the window, and it is an OUTPUT pane rather than the // shell a bare `pardes` boots: the answer is the whole screen. try std.testing.expectEqual(@as(u8, 1), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); try std.testing.expectEqual(@as(usize, 0), p.col_panes[0][0]); try std.testing.expectEqual(@as(usize, 0), p.active); for (p.panes[1..]) |slot| try std.testing.expect(slot == null); const pane = p.panes[0].?; try std.testing.expect(!pane.isTerminal()); const f = pane.file.?; try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // ...and it says what happened AND what was asked for. The word as typed, // which is the half a bare "not found" leaves out. try std.testing.expect(std.mem.indexOf(u8, f.content, "not found") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "notes/tdoo.md") != null); try std.testing.expectEqualStrings("/home/pardes-test/work/+Errors", f.path); } test "raw terminal cursor obeys visibility without hiding modal and tag cursors" { if (comptime !panes.terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; pane.body.mode = .tty; p.update(.{ .output = .{ .pane = 0, .bytes = "abc" } }); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const visible = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; const rect = p.rects[0]; const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; try std.testing.expectEqual(rect.x + config.GUTTER + 3, visible.x); try std.testing.expectEqual(body_y, visible.y); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); try std.testing.expect((try p.render(frame.allocator())).cursor == null); pane.body.mode = .normal; pane.body.cur_pinned = true; pane.body.cur_row = 0; pane.body.cur_col = 1; const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); try std.testing.expectEqual(body_y, modal_cursor.y); tagline.enterTag(p, pane); const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); pane.focus = .body; pane.body.mode = .tty; try std.testing.expect((try p.render(frame.allocator())).cursor == null); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25h\x1b[?1049h\x1b[2;5H" } }); const alternate = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 4, alternate.x); try std.testing.expectEqual(body_y + 1, alternate.y); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); try std.testing.expect((try p.render(frame.allocator())).cursor == null); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l\x1b[?25h" } }); const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(visible.x, returned.x); try std.testing.expectEqual(visible.y, returned.y); } test "raw terminal cursor follows its active row only while that row is visible" { if (comptime !panes.terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; for ([_]bool{ false, true }) |tag_bottom| { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); while (p.nextEffect()) |_| {} p.settings.tag_bottom = tag_bottom; const pane = p.panes[0].?; pane.body.mode = .tty; for (0..60) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "row\r\n" } }); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3;4H" } }); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const live = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; const rect = p.rects[0]; const body_y = if (tag_bottom) rect.y else rect.y + BOX_H; try std.testing.expectEqual(rect.x + config.GUTTER + 3, live.x); try std.testing.expectEqual(body_y + 2, live.y); panes.terminal.scrollGrid(pane, -1); const scrolled = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, scrolled.x); try std.testing.expectEqual(live.y + 1, scrolled.y); panes.terminal.scrollGrid(pane, -@as(i32, pane.rows)); try std.testing.expect((try p.render(frame.allocator())).cursor == null); panes.terminal.followOutput(pane); const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, returned.x); try std.testing.expectEqual(live.y, returned.y); } } test "Esc back into a tty leaves its view at the prompt" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const shell = p.active; const sp = p.panes[shell].?; try std.testing.expect(sp.isTerminal()); // A click pins the modal cursor — one of the several ordinary ways to get a // pin, and the pin is what a shell's rows then scroll out from under. const r = p.rects[shell]; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H } }); try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); try std.testing.expectEqual(Text.Mode.tty, sp.body.mode); // tty mode follows output to the bottom, so a screenful and a half of it // rides the view down and leaves that pin far up in the scrollback. for (0..60) |i| { var buf: [32]u8 = undefined; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); const live = sp.terminal.?.vt.screens.active.pages.scrollbar().offset; try std.testing.expect(live > 0); p.update(.{ .key = .{ .cp = Key.escape } }); // shell-prompt heuristic: out to the doc try std.testing.expect(p.active != shell); p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back in p.sync(); try std.testing.expectEqual(shell, p.active); try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset); } test "a prompt chip is a band with its caret, still on the grid, and only the chip swallows a click" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); const id = p.active; const pane = p.panes[id].?; p.sync(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); // The caret sits AFTER the prompt text the moment the prompt opens, and // the chip ends at the pane's right edge -- so this is the frame that // used to put it one column past the pane, off the grid and past what the // detached wire will encode. p.startPrompt(pane, &pane.body, .{ .search = "/" }); _ = try p.render(frame.allocator()); const r = p.rects[id]; const cursor = p.surface.cursor orelse return error.MissingPromptCursor; const row = p.bodyTop(pane, r); try std.testing.expectEqual(@as(u8, 1), pane.notices.len); try std.testing.expectEqual(row, cursor.y); try std.testing.expect(cursor.x < r.x + r.w); try std.testing.expect(cursor.x < p.surface.cols); // 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.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.x - 1].grapheme()); // The chip covers only its own columns. Left of it is ordinary body, and // a sweep there still selects. const left = pane.notices.left[0]; try std.testing.expect(left > r.x + config.GUTTER + 2); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = left - 3, .row = row } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = left - 1, .row = row } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = left - 1, .row = row } }); try std.testing.expect(pane.sel[0].state != .none); try std.testing.expect(pane.sel[0].r0 >= BOX_H); // the body, not the tag // Inside it is chrome: mapped as body it would land on Sel row 0, which is // the TAG row, so a sweep there would highlight the tagline instead. pane.clearPointerSelection(0); pane.sel[0] = .{}; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + r.w - 3, .row = row } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = r.x + r.w - 2, .row = row } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + r.w - 2, .row = row } }); for (pane.sel) |selection| try std.testing.expect(selection.state == .none); } test "a prompt is typed on its own line in the notice band and leaves the tag row alone" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); const pane = try p.setTestFile("one two\n"); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); _ = try p.render(frame.allocator()); const tag_before = try std.testing.allocator.dupe(Cell, p.surface.tag_layers[0].cells); defer std.testing.allocator.free(tag_before); p.update(.{ .key = .{ .cp = '/' } }); p.update(.{ .key = .{ .cp = 't', .text = "tw" } }); p.update(.{ .key = .{ .cp = Key.left } }); try std.testing.expect(pane.prompt == .search and pane.focus == .body); try std.testing.expectEqualStrings(config.search_marker ++ "tw", pane.promptText().?); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); // The tagline is exactly what it was: nothing of the prompt is in it. try std.testing.expectEqual(tag_before.len, p.surface.tag_layers[0].cells.len); for (tag_before, p.surface.tag_layers[0].cells) |was, is| try std.testing.expect(was.visuallyEqual(&is)); 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.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.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); // Submitting searches for what was typed and gives the prompt its line back. p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(pane.prompt == .none and pane.input.own == null); } test "Newcol makes an empty column, and the next pane made from its tag fills it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); defer p.deinit(); const first = p.active; const panes_before = p.col_n[0]; try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); try std.testing.expectEqual(panes_before, p.col_n[0]); // The keyboard is on the new column's tag; no pane has it. try std.testing.expect(p.header_focus and p.header_column.? == 1); try std.testing.expectEqual(first, p.active); p.sync(); try std.testing.expect(p.executeBuiltinLine(first, "Tty")); const shell = p.active; try std.testing.expect(shell != first); try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); try std.testing.expectEqual(shell, p.col_panes[1][0]); try std.testing.expect(!p.header_focus); // The next Tty splits as it always has: nothing is empty any more. p.sync(); try std.testing.expect(p.executeBuiltinLine(shell, "Tty")); try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); } test "a column tag's command acts on that column" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); defer p.deinit(); const first = p.active; try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); try std.testing.expectEqual(@as(usize, 3), p.ncol); p.sync(); // New from the middle column's tag lands in it, not below `first`. p.exec_column = 1; try std.testing.expect(p.executeBuiltinLine(first, "New")); p.exec_column = null; try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); try std.testing.expectEqual(p.active, p.col_panes[1][0]); // Delcol from the empty right column's tag closes that one. p.exec_column = 2; try std.testing.expect(p.executeBuiltinLine(first, "Delcol")); p.exec_column = null; try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); // A pane's own Delcol closes its column whatever tag has the keyboard. try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); try std.testing.expect(p.header_focus); try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol")); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); } test "Del from a key asks which neighbour takes the rows; nothing else asks" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 }); defer p.deinit(); p.settings.placement = .pardes; // the neighbours below are what is under test var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const top = p.active; p.newScratchBelow(top); const mid = p.active; p.newScratchBelow(mid); const low = p.active; p.sync(); var h = [_]u16{ p.rects[top].h, p.rects[mid].h, p.rects[low].h }; // SPC d on the middle pane asks on its notice band, and closes nothing yet. p.active = mid; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expect(p.panes[mid] != null); try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[mid].?.prompt); _ = try p.render(frame.allocator()); try std.testing.expectEqual(Pane.Notices.Kind.prompt, p.panes[mid].?.notices.kinds[p.panes[mid].?.notices.len - 1]); try std.testing.expectEqualStrings(config.del_question, p.panes[mid].?.promptText().?); // j: the pane below takes every row, the one above keeps its own. p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expect(p.panes[mid] == null); try std.testing.expectEqual(h[0], p.rects[top].h); try std.testing.expectEqual(h[1] + h[2], p.rects[low].h); try std.testing.expectEqual(low, p.active); // focus follows the rows // Esc takes the question back and the pane stays; so does any other key, // and so does a click. (Top gets rows back first: split at its cursor, // it kept its tag and two, too few to give a pane below its own.) p.panes[top].?.vweight = p.panes[low].?.vweight; p.sync(); p.newScratchBelow(top); const again = p.active; p.sync(); h = .{ p.rects[top].h, p.rects[again].h, p.rects[low].h }; try std.testing.expect(p.executeBuiltinLine(again, "Msg keep")); for ([_]Key{ .{ .cp = Key.escape }, .{ .cp = 'x' } }) |answer| { p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[again].?.prompt); p.update(.{ .key = answer }); try std.testing.expect(p.panes[again] != null); try std.testing.expectEqual(Pane.Prompt.none, p.panes[again].?.prompt); } p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); const r = p.rects[again]; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + 5, .row = r.y + 3 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + 5, .row = r.y + 3 } }); try std.testing.expect(p.panes[again] != null); try std.testing.expectEqual(Pane.Prompt.none, p.panes[again].?.prompt); try std.testing.expectEqual(h[1], p.rects[again].h); // 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")); p.sync(); try std.testing.expect(p.panes[again] == null); try std.testing.expectEqual(h[0] + h[1], p.rects[top].h); try std.testing.expectEqual(top, p.active); try std.testing.expectEqual(h[2], p.rects[low].h); // A script answers the question on the pane's ctl, as the log's `ask` // names it: `answer -` takes it back, a side closes the pane toward it. { const th = @import("ninep/testing.zig"); p.newScratchBelow(top); const asked = p.active; p.sync(); const serial = p.panes[asked].?.serial; const ctl = ctlfs.Node.of(serial, .ctl); try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctl, "answer k\n").errno()); p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); var want: [32]u8 = undefined; try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} del k j\n", .{serial}))); try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctl, "answer x\n").errno()); try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[asked].?.prompt); try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status); try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none); try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\n", .{serial}))); p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status); try std.testing.expect(p.paneBySerial(serial) == null); try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} k\n", .{serial}))); } // Bare Del from anything but a key closes at once and gives the rows up, // as it always has. p.newScratchBelow(top); const third = p.active; p.sync(); h = .{ p.rects[top].h, p.rects[third].h, p.rects[low].h }; try std.testing.expect(p.executeBuiltinLine(third, "Del")); p.sync(); try std.testing.expect(p.panes[third] == null); try std.testing.expectEqual(h[0] + h[1], p.rects[top].h); // With nothing open below, there is nothing to ask: SPC d closes at once. p.active = low; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); 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.newColumn(shell); p.newScratchIn(shell, p.activeColumn().?); 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(layout.findPane(p, left_low).?.col); p.sync(); try std.testing.expectEqual(right_low, p.active); } test "closing the last pane in a column leaves the column empty, its tag focused" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); defer p.deinit(); const shell = p.active; p.newColumn(shell); p.newScratchIn(shell, p.activeColumn().?); const lone = p.active; try std.testing.expect(lone != shell); try std.testing.expectEqual(@as(usize, 2), p.ncol); const at = layout.findPane(p, lone).?; try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); p.sync(); try p.removePane(lone, null); p.sync(); // The column outlives the pane, as acme's does: still two columns, the // emptied one with no pane and the keyboard on its tag. try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 0), p.col_n[at.col]); try std.testing.expect(p.panes[lone] == null); try std.testing.expect(p.header_focus and p.header_column.? == at.col); try std.testing.expectEqual(shell, p.active); try std.testing.expect(!p.quit); // Down from it goes nowhere: nothing is below. try std.testing.expect(p.executeBuiltinLine(shell, "Down")); try std.testing.expect(p.header_focus and p.header_column.? == at.col); // ...and Delcol is what actually takes the column away. try p.removeColumn(at.col); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqual(shell, p.active); // The session's last pane closing ends it. try p.removePane(shell, null); try std.testing.expect(p.quit); } test "the bare tty startup opens a text pane under the shell, unfocused" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .boot = .tty }); defer p.deinit(); try std.testing.expectEqual(Boot.tty, p.boot); // One column, shell on top in raw mode, an empty text pane under it. try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); const shell = p.panes[p.col_panes[0][0]].?; const scratch = p.panes[p.col_panes[0][1]].?; try std.testing.expect(shell.isTerminal()); try std.testing.expectEqual(Text.Mode.tty, shell.body.mode); try std.testing.expect(!scratch.isTerminal()); try std.testing.expectEqualStrings("", scratch.file.?.content); // ...and the shell keeps the keyboard. try std.testing.expectEqual(p.col_panes[0][0], p.active); // Unnamed, it is the bare shell every test asks for. const plain = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer plain.deinit(); try std.testing.expectEqual(Boot.tty_shell, plain.boot); try std.testing.expectEqual(@as(usize, 1), plain.col_n[0]); } test "tty output follows only from the bottom" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const shell = p.active; const sp = p.panes[shell].?; p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); try std.testing.expectEqual(Text.Mode.tty, sp.body.mode); for (0..60) |i| { var buf: [32]u8 = undefined; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); const bottom = panes.terminal.scrollbar(sp).offset; try std.testing.expect(bottom > 0); // Scrolled back, the reader stays put however much the shell prints. sp.scrollBy(-10); p.sync(); const parked = panes.terminal.scrollbar(sp).offset; try std.testing.expect(parked < bottom); for (60..70) |i| { var buf: [32]u8 = undefined; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); try std.testing.expectEqual(parked, panes.terminal.scrollbar(sp).offset); // Typing snaps back to live output, so nobody types blind. p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); p.sync(); try std.testing.expect(panes.terminal.scrollbar(sp).offset > parked); // ...and from there output follows again. const live = panes.terminal.scrollbar(sp).offset; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "tail\r\n" } }); p.sync(); try std.testing.expect(panes.terminal.scrollbar(sp).offset > live); } test "Esc back into a file leaves its view where it was" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/memory.zig" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const shell = p.active; p.update(.{ .key = .{ .cp = Key.escape } }); // back onto the doc p.sync(); const doc = p.active; try std.testing.expect(doc != shell); const dp = p.panes[doc].?; try std.testing.expect(dp.file != null); // Ride the cursor down until the view has scrolled: it now sits in the // bottom band, which is exactly where a recenter is visible. for (0..20) |_| p.update(.{ .key = .{ .cp = 'j' } }); p.sync(); const view = dp.file.?.scroll; const row = dp.body.cur_row; try std.testing.expect(view > 0); p.update(.{ .key = .{ .cp = Key.escape } }); // out to the shell p.sync(); try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back p.sync(); try std.testing.expectEqual(doc, p.active); // Nothing moved. The old landing recentred the line under the cursor, so // coming back repainted the whole screen to show what was already on it. try std.testing.expectEqual(view, dp.file.?.scroll); try std.testing.expectEqual(row, dp.body.cur_row); // ...and the cursor is still on screen, which is all `.nearest` promises. try std.testing.expect(dp.body.cur_row >= @as(i32, @intCast(view))); try std.testing.expect(dp.body.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows)); // The other arm still centres: a look target, a `:NN`, a search hit. look.focusPaneLine(p, doc, .{ .line = @intCast(row + 1), .col = 1 }, .center); try std.testing.expect(dp.file.?.scroll != view); try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll); } 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); const shell = p.active; const pane = p.panes[shell].?; var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); // Ordinary keys reach the child; Shift-Escape is the one unconditional way // out and sends it nothing. p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); try std.testing.expectEqual(shell, p.active); try std.testing.expect(drainWrites(p, &buf).len > 0); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); p.update(.{ .key = .{ .cp = Key.escape } }); // back into the shell try std.testing.expectEqual(shell, p.active); _ = drainWrites(p, &buf); p.update(.{ .key = .{ .cp = Key.escape } }); // bare Escape leaves at a prompt try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); } 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); const shell = p.active; p.sync(); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); try std.testing.expectEqual(shell, p.active); try std.testing.expectEqual(Text.Mode.normal, p.panes[shell].?.body.mode); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); } test "the jump list never holds two entries in a row the same, when the one between them goes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 40 }); defer p.deinit(); const pane = try p.setTestFile("a\n" ** 200); const at: Loc = .{ .pane = 0, .serial = pane.serial, .line = 1, .col = 1 }; // A, a pane long closed with no file to keep it, A again, then far on. p.jumps[0] = at; p.jumps[1] = .{ .pane = 5, .serial = 999, .line = 1, .col = 1 }; p.jumps[2] = at; p.jumps[3] = .{ .pane = 0, .serial = pane.serial, .line = 150, .col = 1 }; p.njumps = 4; p.jcur = 3; pane.body.cur_row = 80; // where you are now: far from both p.trackJump(); for (1..p.njumps) |i| try std.testing.expect(!std.meta.eql(p.jumps[i - 1], p.jumps[i])); try std.testing.expectEqual(@as(usize, 3), p.njumps); try std.testing.expectEqual(@as(u32, 1), p.jumps[0].line); try std.testing.expectEqual(@as(u32, 150), p.jumps[1].line); } test "Callers with one answer jumps to it and says where, in a msg" { if (!lsp.supports.contains(.incoming_calls)) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("fn a() {}\nfn b() { a(); }\n"); p.lspRequest(p.active, .incoming_calls, ""); const wait = p.lsp_wait.?; p.update(.{ .lsp_resp = .{ .id = wait.id, .rows = "/test.txt:2:10 b\n" } }); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_row); try std.testing.expectEqualStrings("Callers: the one, /test.txt:2:10", pane.msg[0..pane.msg_len]); } test "a Rename the language server finds nothing for says so, and changes nothing" { if (!lsp.supports.contains(.rename)) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("const value=1;\n"); p.lspRequest(p.active, .rename, "renamed"); const wait = p.lsp_wait.?; p.update(.{ .lsp_resp = .{ .id = wait.id, .rows = "" } }); try std.testing.expectEqualStrings("Rename: nothing here to rename", pane.msg[0..pane.msg_len]); try std.testing.expectEqualStrings("const value=1;\n", pane.file.?.content); } test "hopping between two panes does not grow the jump stack" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const doc = 0; const shell = p.active; try std.testing.expect(shell != doc); const depth = p.njumps; // Esc is Last — the everyday two-pane hop. Each press used to // append, so sixty-four of them left the stack remembering nothing else. for (0..8) |_| { const was = p.active; p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(p.active != was); } try std.testing.expectEqual(depth, p.njumps); // ...and so are the other two ways to switch panes. for (0..8) |_| { const want = if (p.active == doc) shell else doc; const dir: u21 = if (p.active == doc) 'j' else 'k'; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'w' } }); p.update(.{ .key = .{ .cp = dir } }); try std.testing.expectEqual(want, p.active); } try std.testing.expectEqual(depth, p.njumps); for (0..8) |_| { const other = if (p.active == doc) shell else doc; const r = p.rects[other]; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H + 1 } }); try std.testing.expectEqual(other, p.active); } try std.testing.expectEqual(depth, p.njumps); const before = p.active; exec.runBuiltin(p, .Back, p.active, "", null); p.sync(); try std.testing.expect(p.active != before); try std.testing.expectEqual(depth, p.njumps); } /// Every `.write` a keystroke produced, joined — what the pane's program would /// actually have read off its pty. fn drainWrites(p: *Pardes, buf: []u8) []const u8 { var n: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { const b = w.bytes.slice(); if (n + b.len > buf.len) break; @memcpy(buf[n..][0..b.len], b); n += b.len; }, else => {}, }; return buf[0..n]; } test "raw tty forwards Ctrl letters and former global shortcuts to the child" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); edit.setYank(p, "register text"); for ('a'..'z' + 1) |letter| { if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut if (letter == 'v') continue; // the window's paste, asserted just below p.update(.{ .key = .{ .cp = @intCast(letter), .ctrl = true } }); const expected = [_]u8{@intCast(letter - 'a' + 1)}; // The terminal encoder distinguishes Ctrl-I/M from Tab/Return. const encoded: []const u8 = switch (letter) { 'i' => "\x1b[105;5u", 'm' => "\x1b[109;5u", else => &expected, }; try std.testing.expectEqualStrings(encoded, drainWrites(p, &buf)); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(Text.Mode.tty, p.panes[0].?.body.mode); try std.testing.expect(!p.ctrl_w_pending and p.clip_pending == null); } // ...and the two paste chords are the exception: Ctrl-V types the register // at the program, Ctrl-Shift-V asks the desktop first and types nothing yet. p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); try std.testing.expectEqualStrings("register text", drainWrites(p, &buf)); try std.testing.expect(p.clip_pending == null); p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); try std.testing.expect(p.clip_pending != null); const unopened = p.freeSlot(); // whatever the boot layout already used for ([_]u8{ 'n', 'c' }) |letter| { p.update(.{ .key = .{ .cp = letter, .alt = true } }); const expected = [_]u8{ 0x1b, letter }; try std.testing.expectEqualStrings(&expected, drainWrites(p, &buf)); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(unopened, p.freeSlot()); } p.update(.{ .key = .{ .cp = Key.up } }); try std.testing.expectEqualStrings("\x1b[A", drainWrites(p, &buf)); p.panes[0].?.terminal.?.vt.modes.set(.cursor_keys, true); p.update(.{ .key = .{ .cp = Key.up } }); try std.testing.expectEqualStrings("\x1bOA", drainWrites(p, &buf)); p.update(.{ .key = .{ .cp = Key.left, .ctrl = true } }); try std.testing.expectEqualStrings("\x1b[1;5D", drainWrites(p, &buf)); } test "an unasked desktop paste reaches a tty pane's program, not its buffer" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); const pane = p.panes[0].?; panes.terminal.enterTty(p, 0); // No request behind it: the window manager's own paste, or SDL answering a // Ctrl-Shift-V the desktop handled. It still has to reach the shell. p.update(.{ .paste = "ls -la" }); try std.testing.expectEqualStrings("ls -la", drainWrites(p, &buf)); // and nothing was typed into an overlay the pty does not know about try std.testing.expect(pane.ovl == null); } test "a paste larger than the effect ring reaches the program whole and in order" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); panes.terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); for (text, 0..) |*c, i| c.* = 'a' + @as(u8, @intCast(i % 26)); p.update(.{ .paste = text }); try std.testing.expectEqualSlices(u8, text, drainWrites(p, buf)); // ...and the core is not still holding a copy afterwards try std.testing.expectEqual(@as(usize, 0), p.pending_write_bytes); } test "a bracketed paste larger than the ring still closes its bracket" { if (platform == .web) return; if (comptime !panes.terminal.enabled) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); panes.terminal.enterTty(p, 0); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); try std.testing.expect(panes.terminal.bracketedPaste(p.panes[0].?)); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'z'); p.update(.{ .paste = text }); const got = drainWrites(p, buf); try std.testing.expect(std.mem.startsWith(u8, got, "\x1b[200~")); try std.testing.expect(std.mem.endsWith(u8, got, "\x1b[201~")); try std.testing.expectEqualSlices(u8, text, got["\x1b[200~".len .. got.len - "\x1b[201~".len]); } test "pasted bytes still queued at shutdown are freed, not leaked" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); panes.terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'q'); p.update(.{ .paste = text }); try std.testing.expect(p.pending_write_bytes > 0); } test "leaving tty hides the prompt and keeps the command typed at it" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const shell = p.active; const sp = p.panes[shell].?; try std.testing.expect(sp.isTerminal()); // Exactly what an OSC 133 shell draws: prompt, then the marker that says // the rest of this row is the user's, then what they typed. One grid row. p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "\x1b]133;A\x1b\\user@box ~/src $ \x1b]133;B\x1b\\grep -rn TODO src/", } }); p.sync(); const rowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { const rows = panes.terminal.shellRows(pp, pane) catch return null; for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; const bodyRowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { const body = panes.terminal.bodyText(pp.scratch.allocator(), pane) catch return null; var it = std.mem.splitScalar(u8, body, '\n'); while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; // The BODY is what you look at, and it follows the mode: in tty the pane // is the program's own screen, so the prompt is there. sp.body.mode = .tty; try std.testing.expectEqualStrings( "user@box ~/src $ grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); sp.body.mode = .normal; try std.testing.expectEqualStrings( "grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); p.shell_rows.stale = true; try std.testing.expectEqualStrings( "grep -rn TODO src/", rowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); // ...and a prompt with nothing typed at it yet is all chrome, so the whole // row goes, which is what it has always done. p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "\r\n\x1b]133;A\x1b\\user@box ~/src $ \x1b]133;B\x1b\\", } }); p.sync(); p.shell_rows.stale = true; try std.testing.expect(rowOf(p, sp, "user@box") == null); } test "entering tty walks the shell cursor to the column clicked past the prompt" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); const shell = p.active; const sp = p.panes[shell].?; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "\x1b]133;A;cl=line\x1b\\prompt> \x1b]133;B\x1b\\0123456789", } }); p.sync(); sp.body.mode = .normal; p.shell_rows.stale = true; // The command shows LEFT-HUGGED, so its column 3 is the '3'... const rows = try panes.terminal.shellRows(p, sp); var row: i32 = 0; const at = for (rows, 0..) |r, i| { if (std.mem.indexOf(u8, r, "0123456789") != null) break i; } else return error.MissingInputRow; row = @intCast(at); try std.testing.expectEqualStrings("0123456789", rows[at]); // ...but the shell's own cursor lives on the real grid, eight cells // further right, behind the prompt this pane is not showing. sp.body.cur_row = row; sp.body.cur_col = 3; sp.body.cur_pinned = true; while (p.nextEffect()) |_| {} panes.terminal.enterTty(p, shell); // The walk is arrow keys the shell understands. Seven lefts: readline's // cursor sits past the '9' and the click was on the '3'. var lefts: usize = 0; var rights: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |wr| { if (std.mem.eql(u8, wr.bytes.slice(), "\x1b[D")) lefts += 1; if (std.mem.eql(u8, wr.bytes.slice(), "\x1b[C")) rights += 1; }, else => {}, }; try std.testing.expectEqual(@as(usize, 0), rights); try std.testing.expectEqual(@as(usize, 7), lefts); } const board_cols: u16 = 56; const board_rows: u16 = 14; fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { return .{ .cols = cols, .rows = rows, .tty_only = true, .frame_allocator = gpa, .image_allocator = gpa, .pdf_allocator = gpa, .tree_sitter_allocator = gpa, }; } fn bootAndTearDown(gpa: std.mem.Allocator, cols: u16, rows: u16) !void { const p = try Pardes.init(gpa, boardBudgetOptions(gpa, cols, rows)); p.deinit(); } test "board heap: every inline ring the board pays for still fits its budget" { const heap = limits.board_heap_bytes; const effect_ring = 128 * @sizeOf(Effect); // ...and the undo history, the largest in `Pane` once the terminal is out: // 16 snapshots on the board against 256 on a desktop. const undo_history = 2 * 16 * @sizeOf(panes.terminal.Snapshot); const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff)); // Each of the three separately, so a failure names the one that grew // instead of reporting a total nobody can attribute. try std.testing.expect(effect_ring <= heap / 8); try std.testing.expect(undo_history <= heap / 32); try std.testing.expect(grid <= heap / 8); try std.testing.expect(effect_ring + undo_history + grid <= heap / 2); } test "board heap: transition grids fit the budget and the surface borrows its snapshots" { const per_cell = 2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff); const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16, .tty_only = true }); 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()); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); const cells = @as(usize, p.screen_w) * p.screen_h; try std.testing.expectEqual(cells, p.surface.cells.len); try std.testing.expectEqual(cells, p.presentation.previous_cells.len); try std.testing.expectEqual(cells, p.presentation.diffs.len); try std.testing.expect(p.surface.previous_cells.ptr == p.presentation.previous_cells.ptr); try std.testing.expect(p.surface.cell_diffs.ptr == p.presentation.diffs.ptr); try std.testing.expect(p.surface.cells.ptr != p.surface.previous_cells.ptr); const owned = p.surface.cells.len * @sizeOf(Cell) + p.presentation.previous_cells.len * @sizeOf(Cell) + p.presentation.diffs.len * @sizeOf(PanelCellDiff); try std.testing.expectEqual(cells * per_cell, owned); try std.testing.expect(@as(usize, board_cols) * board_rows * per_cell <= limits.board_heap_bytes / 8); } test "LSP failure clears only its matching wait without completion indentation" { if (!lsp.supports.contains(.completion)) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("x"); pane.body.mode = .insert; pane.body.cur_col = 1; p.lspRequest(p.active, .completion, ""); const old_id = p.lsp_wait.?.id; p.lspRequest(p.active, .completion, ""); const current_id = p.lsp_wait.?.id; p.update(.{ .lsp_resp = .{ .id = old_id, .rows = null } }); try std.testing.expectEqual(current_id, p.lsp_wait.?.id); p.update(.{ .lsp_resp = .{ .id = current_id, .rows = null } }); try std.testing.expect(p.lsp_wait == null); try std.testing.expectEqualStrings("x", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); p.lspRequest(p.active, .completion, ""); p.update(.{ .lsp_resp = .{ .id = p.lsp_wait.?.id, .rows = "" } }); try std.testing.expect(p.lsp_wait == null); try std.testing.expectEqualStrings("x ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 4), pane.body.cur_col); } test "board heap: every allocation failure during boot is a clean OutOfMemory" { try std.testing.checkAllAllocationFailures( std.testing.allocator, bootAndTearDown, .{ board_cols, board_rows }, ); } test "jump history clears selections and lands at the recorded cursor" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n"); p.njumps = 2; p.jcur = 1; p.jumps[0] = .{ .pane = 0, .serial = pane.serial, .line = 3, .col = 8 }; p.jumps[1] = .{ .pane = 0, .serial = pane.serial, .line = 1, .col = 1 }; for ([_]u21{ 'o', 'i' }, 0..) |key, index| { pane.body.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); pane.sel[0] = .{ .state = .done, .c0 = 2, .c1 = 5, .r0 = 1, .r1 = 1 }; try edit.capturePointerSelection(p, pane, 0); pane.body.select = true; p.update(.{ .key = .{ .cp = key, .ctrl = true } }); try std.testing.expectEqual(index, p.jcur); 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(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); try std.testing.expectEqual(@as(i32, if (index == 0) 7 else 0), pane.body.cur_col); } pane.body.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); const selected = pane.body.primaryRange(pane.file.?.content, 0); p.jumpBy(1); // No forward entry: selection stays untouched. try std.testing.expectEqualDeep(selected, pane.body.primaryRange(pane.file.?.content, 0)); try std.testing.expect(std.mem.indexOf(u8, p.panes[p.active].?.msg[0..p.panes[p.active].?.msg_len], "nothing further on") != null); for ([_]Loc{ .{ .pane = 15, .serial = pane.serial, .line = 1, .col = 1 }, .{ .pane = 0, .serial = pane.serial + 1, .line = 1, .col = 1 }, }) |missing| { p.jumps[0] = missing; p.jumpBy(-1); try std.testing.expectEqual(@as(usize, 1), p.jcur); try std.testing.expectEqualDeep(selected, pane.body.primaryRange(pane.file.?.content, 0)); } } test "jump history terminal rectangle follows output and clears on reflow" { if (comptime !terminal_panes) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; p.update(.{ .output = .{ .pane = 0, .bytes = "alpha bravo\r\nsecond line\r\n" } }); pane.sel[0] = .{ .state = .done, .c0 = 0, .c1 = 4, .r0 = BOX_H, .r1 = BOX_H }; try edit.capturePointerSelection(p, pane, 0); try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0])); for (0..40) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "more output\r\n" } }); try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0])); panes.terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows); 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()); } test "a pane closed while its question stands ends the question in the log" { const th = @import("ninep/testing.zig"); const p = try th.withFile(std.testing.allocator, "x\n"); defer p.deinit(); p.settings.placement = .pardes; p.newScratchBelow(0); const closing = p.active; p.newScratchBelow(closing); p.sync(); p.active = closing; // a middle pane: Del from a key asks which side const serial = p.panes[closing].?.serial; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt); // A bare answer says what this question takes. try std.testing.expectEqualStrings("answer takes k j or -", th.wr(p, ctlfs.Node.of(serial, .ctl), "answer\n").reply.ename); try p.removePane(closing, null); var want: [64]u8 = undefined; try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ serial, serial }))); } test "a Save the host could not do logs no save record" { const th = @import("ninep/testing.zig"); const p = try th.withFile(std.testing.allocator, "x\n"); defer p.deinit(); const Failing = struct { core: *Pardes, const vtable: Host.VTable = .{ .write_file = write }; fn write(ctx: ?*anyopaque, pane: u8, path: []const u8, _: []const u8) void { const f: *@This() = @ptrCast(@alignCast(ctx.?)); f.core.saveFailed(pane, path, error.AccessDenied); } }; var host: Failing = .{ .core = p }; p.host = .{ .ctx = &host, .vtable = &Failing.vtable }; _ = th.wr(p, ctlfs.Node.of(p.panes[0].?.serial, .body), "more\n"); p.perform(.{ .save_file = .{ .pane = 0 } }); try std.testing.expect(!th.logHas(p, "\nsave ")); try std.testing.expect(p.panes[0].?.file.?.saved_revision != p.panes[0].?.file.?.revision); // With the host's write done, it is logged. p.host = .{}; p.perform(.{ .save_file = .{ .pane = 0 } }); try std.testing.expect(th.logHas(p, "\nsave ")); } test "Delcol on the last column leaves the window, drawn and typed into, and Newcol starts it again" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); while (p.nextEffect()) |_| {} while (p.ncol > 0) { p.exec_column = 0; _ = p.executeBuiltinLine(p.active, "Delcol"); p.exec_column = null; p.sync(); } try std.testing.expect(!p.quit); try std.testing.expect(p.anyPane() == null); // Drawn, and keys go to the workspace tag. _ = try p.render(p.frame_arena.allocator()); p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); p.update(.{ .key = .{ .cp = Key.enter } }); p.sync(); _ = try p.render(p.frame_arena.allocator()); try std.testing.expect(!p.quit); _ = p.executeBuiltinLine(p.active, "Newcol"); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.ncol); _ = p.executeBuiltinLine(p.active, "New"); p.sync(); try std.testing.expect(p.anyPane() != null); _ = try p.render(p.frame_arena.allocator()); }