const std = @import("std"); pub const layout = @import("layout.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); const mvzr = @import("mvzr"); 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"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const Messages = @import("Messages.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 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, /// Both 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 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; pub const MAX_PANES = 16; pub const BodyLayer = @import("body_layer.zig").Layer; pub const RowMetrics = @import("body_layer.zig").Metrics; pub const TagLayer = @import("tag_layer.zig").Layer; pub const TagHit = @import("tag_layer.zig").Hit; pub const TagKind = @import("tag_layer.zig").Kind; /// Panes, then columns, then the workspace bar, then each pane's notice bands. pub const 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; pub const MAX_COLS = 6; 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 TagLine = @import("tag_line.zig").Line; 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: ?layout.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 tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y; if (@as(f32, @floatFromInt(row)) >= tag_y and @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(BOX_H))) return box.x; } for (p.panes, 0..) |slot, id| { if (slot == null) continue; const r = p.rects[id]; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; if (row == tag_y 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 = layout.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 = layout.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), layout.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| { if (slot == null) continue; const r = core.rects[id]; const tag_y = if (core.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; if (row != tag_y 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)); } pub const Loc = struct { pane: u16, serial: u32, line: u32, col: u32, }; test { _ = @import("edit.zig"); _ = @import("Messages.zig"); _ = @import("selection_pipe.zig"); _ = @import("colors.zig"); _ = @import("surface.zig"); _ = @import("body_layer.zig"); _ = @import("tag_layer.zig"); } const pane_tail = " " ++ config.pane_builtins_str; const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; const context_pane_tail = file_pane_tail ++ " TreeContext"; const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; const dirty_marker = " *"; const legacy_pane_tail = " Del"; const legacy_file_pane_tail = " Save Del"; // The defaults from the release before Newtty joined every tagline. Recognized // so a dump written then upgrades instead of keeping the old word as a tail. const prev_pane_tail = " New Del"; const prev_file_pane_tail = " Save New Del"; const legacy_terminal_pane_tail = " New Del Filter"; // ...and the terminal default from the release before Save reached it, when a // scrollback was not yet something you could write to a path. const prev_terminal_pane_tail = " New Newtty Del Filter"; const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter"; const pre_column_pane_tail = " New Newtty Del"; const pre_column_file_pane_tail = " Save New Newtty Del"; const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; const pre_collapse_pane_tail = " Tty Del"; const pre_collapse_file_pane_tail = " Save Tty Del"; const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; // ...and the defaults from before the closing word moved to the end of every // tagline, where a click cannot land on it by overshooting the one before. const pre_close_last_pane_tail = " Tty Del Collapse"; const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; const Builtin = builtins.registry.Builtin(); pub const test_api = if (@import("builtin").is_test) struct { pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { return p.tagText(arena, pane); } 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; p.runBuiltin(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 p.searchStep(id, delta); } pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { return p.heldSelection(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 "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: NotAttached", pane.msg[0..pane.msg_len]); try std.testing.expect(!p.quit); } test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const long = "a" ** 400; const pane = try p.setTestFile(long ++ "\nsecond\n"); p.settings.wrap = true; const width = panes.File.wrapWidth(pane, true); try std.testing.expect(width > 4 and long.len > width * 3); // gj holds the column INSIDE the row and lands on the next break; the line // is one line the whole way down, which is the whole point. pane.cur_row = 0; pane.cur_col = 3; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, @intCast(width * 2 + 3)), pane.cur_col); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col); // A count is counted in rows, not lines. p.update(.{ .key = .{ .cp = '2' } }); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expectEqual(@as(i32, @intCast(width * 3 + 3)), pane.cur_col); // Plain j/k are untouched: one FILE line each, whatever the body drew. pane.cur_col = 3; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); // The last row of a line steps into the next line's first row, and the // first row steps back onto the previous line's LAST row. pane.cur_row = 0; pane.cur_col = @intCast(long.len - 1); pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); pane.cur_col = 0; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expect(pane.cur_col >= @as(i32, @intCast(long.len - long.len % width))); // Wrap OFF: a line is one visual row, so gj/gk ARE j/k again. p.settings.wrap = false; pane.cur_row = 0; pane.cur_col = 3; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); } test "the acme chords act once per selection, not once on the primary" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // Two selections, each naming a DIFFERENT builtin, so what ran is visible // in the layout rather than in a shell nobody can read from a test. const pane = try p.setTestFile("Newcol\nNewcol\n"); const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 6 }, .{ .anchor = 7, .head = 13 }, }; pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); try std.testing.expectEqual(@as(u8, 1), pane.nsel); const before = p.ncol; p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec // BOTH ran. Before this the chord read the primary range and dropped the // other cursor, so one keystroke over two cursors made one column. try std.testing.expectEqual(before + 2, p.ncol); // ...and the chord consumed the selection exactly as it does with one. try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.nsel); } 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 \\Kill 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(), "Kill") == null); }, else => {}, }; } test "scene effects keep the idle frontend animation clock active" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); try std.testing.expect(!p.animationActive()); p.settings.scene_effects.crt = true; try std.testing.expect(p.animationActive()); p.settings.scene_effects = .{ .ripple = true }; try std.testing.expect(p.animationActive()); p.settings.scene_effects = .{ .glitch = true }; try std.testing.expect(p.animationActive()); p.settings.scene_effects = .{}; try std.testing.expect(!p.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); const pane = p.panes[0].?; try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(pane)); const tag_x = p.rects[0].x + TAG_TEXT_INSET; const tag_y = p.rects[0].y; const rendered = try p.tagText(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()); // 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. pane.tag_tail_len = 0; try std.testing.expect(pane.appendTag(" Theme dark")); pane.tag_init = true; 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 Kill 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, "Kill 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. pane.tag_tail_len = 0; try std.testing.expect(pane.appendTag(" 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{ pane_tail, file_pane_tail, pdf_pane_tail, 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); p.enterTagEdit(pane, -1); 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); Pardes.exitTagEdit(pane); 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); // Unedited defaults from the preceding version gain the new command. pane.tag_init = false; pane.tag_tail_len = 0; p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); try std.testing.expect(!pane.tag_init); try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); } 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.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 p.dumpState(); const restored = try Pardes.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.cols != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; try std.testing.expectEqual(p.bodyTop(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(r)).style.font_role); try p.dumpState(); const restored = try Pardes.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(file_pane_tail, Pardes.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(context_pane_tail, Pardes.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); try std.testing.expect(pane.appendTag(" Save Keep Del")); pane.tag_init = true; try p.dumpState(); const restored = try Pardes.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", Pardes.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, Pardes.curTail(pane), " Mode ") != null); p.seedTail(pane); try std.testing.expect(pane.appendTag(" Togglettymode")); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Pane.Mode.normal, pane.mode); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); p.enterTagEdit(pane, -1); try std.testing.expectEqual(Pane.Mode.insert, pane.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Pane.Mode.normal, pane.mode); try std.testing.expect(!pane.tag_edit); p.enterTagEdit(pane, -1); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try std.testing.expect(!pane.tag_edit); p.acknowledgePanelPresentation(&.{}); const tag = try p.tagText(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(Pane.Mode.normal, pane.mode); const file = try p.setTestFile("unchanged\n"); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Pane.Mode.normal, file.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 p.tagText(gpa, above); defer gpa.free(above_tag); const below_tag = try p.tagText(p.scratch.allocator(), below); try std.testing.expect(below_tag.len > above_tag.len); const tail = 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 p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} p.seedTail(below); try std.testing.expectEqualStrings(tail, below.tagSlice()); try std.testing.expect(below.appendTag(" lots and lots of typing out here")); try std.testing.expectEqual(above_tag.len, (try p.tagText(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 p.tagText(p.scratch.allocator(), above)); } test "legacy default tag tails upgrade while custom tails remain owned" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); const terminal = p.panes[0].?; const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), legacy_pane_tail, }); p.restoreTail(terminal, terminal_old); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_previous); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), prev_terminal_pane_tail, }); p.restoreTail(terminal, terminal_prefilter); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); for ([_][]const u8{ pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail }) |old_tail| { const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), old_tail }); p.restoreTail(terminal, old); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); } const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try p.tagPrefix(terminal), pre_mode_terminal_pane_tail }); p.restoreTail(terminal, custom_mode); try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(pre_mode_terminal_pane_tail ++ " Keep", Pardes.curTail(terminal)); terminal.tag_tail_len = 0; terminal.tag_init = false; const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_custom); try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); terminal.tag_tail_len = 0; terminal.tag_init = false; p.restoreDumpTail(terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd Keep Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); terminal.tag_tail_len = 0; terminal.tag_init = false; p.restoreDumpTail(terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd New Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. terminal.tag_tail_len = 0; terminal.tag_init = false; const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_fileish); try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal)); // A savable file has a distinct old default. Save remains first after the // migration so the tag's established `:w` route is unchanged. const file = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(file), legacy_file_pane_tail, }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), }); p.restoreTail(file, file_custom); try std.testing.expect(file.tag_init); try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file)); // Conversely the generic pane's current default is a custom edit on a // savable file. Legacy recognition is scoped to the source pane class. file.tag_tail_len = 0; file.tag_init = false; const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(file), }); p.restoreTail(file, file_generic); try std.testing.expect(file.tag_init); try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", .body = "", .image = .{ .path = "/tmp/legacy image.ppm" }, }); try std.testing.expect(image_doc.tag_init); try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice()); } 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); 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); // 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.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 "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.mode == .tty); try std.testing.expect(pane.v9fs_on_spawn); try std.testing.expect(!pane.greet); try std.testing.expect(!p.takesCommandLine(p.active)); panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); try std.testing.expect(p.takesCommandLine(p.active)); panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07"); try std.testing.expect(!p.takesCommandLine(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(); 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().?; const doc = try p.openTutorView(free); p.placeDoc(from, free, doc); tty.spawnTty(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 "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(); const shell = p.panes[0].?; const doc = try p.openTutorView(1); p.placeDoc(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 "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{ pane_tail, file_pane_tail, pdf_pane_tail, 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", p.columnTag(0)); const terminal = p.panes[0].?; p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); const file = try p.setTestFile("unchanged\n"); p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); try std.testing.expect(!file.tag_init); try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); } test "an unsaved file marker sits between its path and builtins until Save" { 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 p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); const dirty = try p.tagText(p.scratch.allocator(), pane); const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); const saved = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); panes.File.changed(p, 0, "external\n"); const reloaded = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); const output = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } test "unknown Exec from an image writes to a terminal in the image directory" { 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-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)); try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback")); 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; }, .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal, else => {}, }; try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } 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 spawns a shell 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 = p.execute(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); var spawned: ?Effect = null; var sent: [256]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; try std.testing.expectEqual(@as(u8, @intCast(dst)), 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 => spawned = effect, else => {}, }; const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane); // ...in the directory the command was about, which is the taken pane's own try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); try std.testing.expectEqual(@as(usize, 0), sent_len); p.acknowledgeShell(dst, "/bin/sh", false); while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, else => {}, }; try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]); } 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; try std.testing.expect(pane.appendTag(" Keep Del")); pane.tag_init = true; try p.dumpState(); 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 Pardes.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_init); try std.testing.expectEqualStrings(" Keep Del", restored_pane.tagSlice()); try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try restored.dumpState(); 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 "Exec from a document pane skips an occupied terminal in its directory and spawns" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} 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") }; var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; const dst = p.execute(1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; try std.testing.expect(dst != 0 and dst != 1); var spawned: ?Effect = null; var sent: [256]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; try std.testing.expectEqual(@as(u8, @intCast(dst)), 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 => spawned = effect, else => {}, }; const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); try std.testing.expectEqual(@as(usize, 0), sent_len); p.acknowledgeShell(dst, "/bin/sh", false); while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, else => {}, }; try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } 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; p.lookAt(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; p.lookAt(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(p.execute(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), p.execute(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... _ = p.execute(0, "echo lazy"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); host.taken[0] = true; host.asked = 0; _ = p.execute(0, "echo lazy-again"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), 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()); const effects = p.effects_len; const free = p.freeSlot().?; try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1])); try std.testing.expect(p.panes[free] == null); p.saveTo(0, "file.txt"); try std.testing.expectEqual(effects, p.effects_len); try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); const current = pane.cwdSlice(); allocator.fail_index = allocator.alloc_index; p.setCwd(0, "/replacement"); try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); 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(Pane.Mode.normal, parent.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 p.dumpState(); 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 = p.restore(p.dump_out.?) 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"); p.saveTo(id, "saved.txt"); 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.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); // typing the filename and submitting converts it into an ordinary file try std.testing.expect(np.appendTag("note.txt")); p.submitSave(id); try std.testing.expect(np.file.?.output == null); try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path); var saved = false; while (p.nextEffect()) |effect| switch (effect) { .save_file => |sf| if (@as(usize, sf.pane) == id) { saved = true; }, else => {}, }; try std.testing.expect(saved); } 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); try std.testing.expect(pane.appendTag("log.txt")); p.submitSave(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(file_pane_tail, Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); p.submitSave(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, "save", 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(locations_pane_tail, Pardes.curTail(out)); // A dump written before Save reached output buffers carries the generic // tail. That is a DEFAULT and not something typed, so it upgrades. const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(out), pane_tail, }); p.restoreTail(out, dumped); try std.testing.expect(!out.tag_init); try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out)); // A scratch is an output buffer that has worn the FILE tails all along, // so its own older default upgrades from that family too. out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } }; out.tag_tail_len = 0; out.tag_init = false; const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(out), prev_file_pane_tail, }); p.restoreTail(out, scratch_dumped); try std.testing.expect(!out.tag_init); try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.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", Pardes.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); p.submitSave(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.yank = try gpa.dupe(u8, "copied"); 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; p.runBuiltin(help, 0, "", null); try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != 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: layout.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 source.dumpState(); const restored = try Pardes.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 source.dumpState(); const restored = try Pardes.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(layout.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 source.dumpState(); const restored = try Pardes.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 }; 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) 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 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 "flat text movement and selection replay need no scratch rows" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); defer p.deinit(); const row_text = "one (two) λ\r\n"; const text = try gpa.alloc(u8, 4096 * row_text.len); defer gpa.free(text); for (0..4096) |row| @memcpy(text[row * row_text.len ..][0..row_text.len], row_text); const pane = try p.setTestFile(text); _ = try panes.File.lineIndex(gpa, &pane.file.?); var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.scratch.deinit(); p.scratch = .init(denied.allocator()); pane.cur_row = 4094; p.handleNormal(pane, .{ .cp = 'l' }); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); p.handleNormal(pane, .{ .cp = 'j' }); try std.testing.expectEqual(@as(i32, 4095), pane.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); p.handleNormal(pane, .{ .cp = 'h' }); try std.testing.expectEqual(@as(i32, 0), pane.cur_col); p.executeNormalAction(pane, .{ .textobject = .{ .char = 'w', .around = false } }); try std.testing.expectEqual(@as(i32, 2), pane.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); pane.vsel.active = false; pane.cur_row = 1024; pane.cur_col = 0; pane.nsel = 1; pane.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; p.handleNormal(pane, .{ .cp = 'l' }); p.handleNormal(pane, .{ .cp = 'j' }); try std.testing.expectEqual(@as(u8, 1), pane.nsel); try std.testing.expectEqual(@as(i32, 1025), pane.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); try std.testing.expectEqual(@as(i32, 3073), pane.sels[0].row); try std.testing.expectEqual(@as(i32, 1), pane.sels[0].col); try std.testing.expect(!denied.has_induced_failure); try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity()); pane.nsel = 0; pane.cur_col = 4; p.executeNormalAction(pane, .match_bracket); try std.testing.expect(denied.has_induced_failure); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); denied.fail_index = std.math.maxInt(usize); p.executeNormalAction(pane, .match_bracket); try std.testing.expectEqual(@as(i32, 8), pane.cur_col); } 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.cur_col); // A click in the wide path glyph likewise becomes a byte cursor at the // grapheme start; arrow motion then advances by the full UTF-8 cluster. p.enterTagEdit(pane, 1); try std.testing.expectEqual(@as(u16, 0), pane.tag_col); p.tagInsertKey(pane, .{ .cp = Key.right }); try std.testing.expectEqual(@as(u16, 3), pane.tag_col); p.tagInsertKey(pane, .{ .cp = Key.right }); try std.testing.expectEqual(@as(u16, 6), pane.tag_col); const before = try gpa.dupe(u8, pane.tagSlice()); defer gpa.free(before); p.enterTagEdit(pane, -1); const insertion = pane.tag_col; p.tagInsertKey(pane, .{ .cp = 'λ', .text = "λ" }); try std.testing.expectEqual(insertion + 2, pane.tag_col); p.tagInsertKey(pane, .{ .cp = Key.backspace }); try std.testing.expectEqual(insertion, pane.tag_col); try std.testing.expectEqualStrings(before, pane.tagSlice()); } 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.cur_row = 0; pane.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.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.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(p.expandedSel(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(p.expandedSel(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(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); } test "Look hover waits without mutating the pane and input cancels it" { const delay = config.look_preview_delay_frames orelse return; try std.testing.expect(delay > 0); const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("alpha beta gamma\n"); while (p.nextEffect()) |_| {} const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; _ = try p.newShell(other_id, ""); try std.testing.expect(layout.splitColumn(p, 0, other_id, false)); p.sync(); while (p.nextEffect()) |_| {} var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[p.active]; const word_x = rect.x + config.GUTTER + config.PREFIX_W + 7; const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; const motion = Event{ .mouse = .{ .button = .none, .kind = .motion, .col = word_x, .row = body_y } }; const selections_before = pane.sel; const active_before = p.active; const cursor_before = .{ pane.cur_row, pane.cur_col }; const jumps_before = .{ p.njumps, p.jcur, p.n_look_src }; const effects_before = p.effects_len; p.update(motion); try std.testing.expect(p.look_hover_wait != null); try std.testing.expect(p.look_hover_preview == null); try std.testing.expect(p.animationActive()); var ticks: u16 = 0; while (ticks + 1 < delay) : (ticks += 1) { p.update(.tick); // Repeated motion reports for one cell are noise, not a fresh delay. if (ticks == delay / 2) p.update(motion); try std.testing.expect(p.look_hover_preview == null); } p.update(.tick); const preview = p.look_hover_preview orelse return error.MissingLookHoverPreview; const preview_sel = preview.sel orelse return error.MissingLookHoverSelection; try std.testing.expectEqual(.done, preview_sel.state); try std.testing.expectEqual(@as(i32, config.PREFIX_W + 6), preview_sel.c0); try std.testing.expectEqual(@as(i32, config.PREFIX_W + 9), preview_sel.c1); try std.testing.expect(!p.animationActive()); // Resolving and displaying the operand is observational only. try std.testing.expect(std.meta.eql(selections_before, pane.sel)); try std.testing.expectEqual(active_before, p.active); try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col }); try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src }); try std.testing.expectEqual(effects_before, p.effects_len); _ = frame.reset(.retain_capacity); const surface = try p.render(frame.allocator()); const expected_bg = colors.mix(p.theme().bg orelse p.theme().tag_bg, colors.mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg)); try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg); // Background traffic in another pane does not change the text or geometry // under this pointer and must not strand a stationary hover unarmed. p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } }); try std.testing.expect(p.look_hover_preview != null); const seam_x = p.col_x[0] + p.col_w[0] - 1; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } }); _ = frame.reset(.retain_capacity); const hovered_seam = try p.render(frame.allocator()); try std.testing.expectEqualStrings("╎", hovered_seam.at(seam_x, body_y).grapheme()); p.update(.pointer_leave); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); _ = frame.reset(.retain_capacity); const left_seam = try p.render(frame.allocator()); try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme())); const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } }); for (0..delay) |_| p.update(.tick); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); try std.testing.expect(!p.animationActive()); // File PREFIX_W belongs only to body line numbers. A preview on the tag's // first character must be paintable all the way at tag column zero — which // is TAG_TEXT_INSET, not GUTTER: the grip keeps the gutter and the gap // beside it separates the grip's hit area from the text. const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y; p.look_hover_preview = .{ .col = rect.x + TAG_TEXT_INSET, .row = tag_y, .pane = p.active, .serial = pane.serial, .sel = .{ .state = .done, .c0 = 0, .c1 = 0, .r0 = 0, .r1 = 0 }, }; _ = frame.reset(.retain_capacity); const tag_surface = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = expected_bg }, tag_surface.at(rect.x + TAG_TEXT_INSET, tag_y).style.bg); p.look_hover_preview = null; p.update(motion); try std.testing.expect(p.look_hover_wait != null); p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); // The following host repaint must not resurrect a hover after keyboard // input cancelled its raw-pointer intent. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(p.look_hover_wait == null); } test "cwd changes cancel only the affected pane's Look hover" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3 }); defer p.deinit(); const first = p.panes[0] orelse return error.MissingFirstPane; const other = p.panes[1] orelse return error.MissingOtherPane; p.setCwd(0, "/work/first"); p.setCwd(1, "/work/other"); p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = first.serial }; // Hosts poll cwd every frame. An unchanged answer is observational and a // change reported for another pane cannot invalidate this pane's operand. p.setCwd(0, "/work/first"); try std.testing.expect(p.look_hover_wait != null); p.setCwd(1, "/work/elsewhere"); try std.testing.expect(p.look_hover_wait != null); try std.testing.expectEqual(other.serial, p.panes[1].?.serial); p.setCwd(0, "/work/changed"); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); } test "plain left click clears explicit modal selection" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 }); defer p.deinit(); const pane = try p.setTestFile("abcdef\n"); pane.cur_row = 0; pane.cur_col = 5; pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true }; pane.msel = .{ .active = true, .r0 = 0, .r1 = 0 }; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; const click_x = rect.x + config.GUTTER + config.PREFIX_W + 3; const body_y = rect.y + BOX_H; 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.cur_col); try std.testing.expect(!pane.vsel.active); try std.testing.expect(!pane.msel.active); try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state); } test "selection drag and queued release do not enter panel pending state" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("alpha beta\n"); p.acknowledgePanelPresentation(&.{}); const rect = p.rects[0]; const x = rect.x + config.GUTTER + config.PREFIX_W + 1; const y = rect.y + BOX_H; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } }); try std.testing.expect(!p.presentation.pending); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } }); try std.testing.expect(p.drag == .none); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); } test "editable workspace and column tags replace selections 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); 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 } }); p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); try std.testing.expect(std.mem.startsWith(u8, p.global_tag.slice(""), "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, p.columnTag(0), "éNew")); p.update(.{ .key = .{ .cp = Key.backspace } }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "New")); p.update(.{ .paste = "Grep " }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try p.dumpState(); const restored = try p.restore(p.dump_out.?); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.slice(""), restored.global_tag.slice("")); try std.testing.expectEqualStrings(p.columnTag(0), restored.columnTag(0)); try std.testing.expect(p.col_tags[0].set("")); try p.dumpState(); const empty = try p.restore(p.dump_out.?); defer empty.deinit(); try std.testing.expectEqualStrings("", empty.columnTag(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"); try std.testing.expect(p.col_tags[0].set("Left Find")); _ = p.execute(0, "Newcol"); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); try std.testing.expect(p.col_tags[1].set("Right Grep")); p.active = p.col_panes[0][0]; layout.joinCol(p); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualStrings("Right Grep", p.columnTag(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.newScratchColumn(0); 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(u16, 0), p.topbar_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 = 'H', .text = "Help" } }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help Tty")); const edited = try p.render(frame.allocator()); try std.testing.expectEqual(x + 4, edited.cursor.?.x); // Only the text viewport participates in caret reveal. const long: [80]u8 = @splat('a'); try std.testing.expect(p.col_tags[column].set(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.newScratchColumn(0); 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"); try std.testing.expect(p.col_tags[0].set("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", p.columnTag(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 "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 "ColumnTags reclaims the row and releases hidden header focus without losing 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"); try std.testing.expect(p.col_tags[0].set("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_column != null and p.topbar_col != null); p.update(.{ .command = "ColumnTags" }); try std.testing.expect(!p.settings.column_tags); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); try std.testing.expect(p.header_column == null and p.topbar_col == null); try std.testing.expect(p.header_anchor == null and !p.header_drag); try std.testing.expectEqualStrings("New Help", p.columnTag(0)); p.update(.{ .command = "ColumnTags" }); try std.testing.expect(p.settings.column_tags); try std.testing.expectEqual(shown, p.rects[0]); p.acknowledgePanelPresentation(&.{}); 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_column != null); p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); try std.testing.expect(p.settings.column_tags); 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_column == null and p.topbar_col == null); p.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); try std.testing.expectEqual(shown, p.rects[0]); try std.testing.expectEqualStrings("New Help", p.columnTag(0)); try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); } test "normal header Enter executes its command rather than searching for it" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.topbar_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); p.header_insert = false; p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(p.topbar_col == null); try std.testing.expect(p.dump_out != null); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); } 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, body_hit: ?BodyHit = null, tag_hit: ?TagHit = null, }; 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 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); fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", .prefix_match => "match_prefix", .prefix_find_fwd => "find_char_fwd", .prefix_find_back => "find_char_back", .prefix_till_fwd => "till_char_fwd", .prefix_till_back => "till_char_back", .prefix_replace => "replace_prefix", .prefix_next => "next_prefix", .prefix_prev => "prev_prefix", else => @tagName(role), }; } fn normalInput(key: Key) modal.Normal.Input { var out: modal.Normal.Input = .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, }; inline for (std.enums.values(modal.Normal.Role)) |role| { const name = comptime roleBindingName(role); if (!@hasDecl(config, name)) @compileError("normal input role has no config binding: " ++ name); const binding = @field(config, name); const matched = if (@TypeOf(binding) == comptime_int) isPrefix(key, binding) else if (@TypeOf(binding) == []const config.Chord) hit(key, binding) else @compileError("normal input binding has unsupported type: " ++ name); if (matched) out.roles.insert(role); } return out; } /// 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 }, eof: struct { pane: 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, 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 }; 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) { spawn: struct { pane: u8, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, signal_pty: struct { pane: u8, sig: PtySignal }, 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) }, write_dump, set_clipboard, read_clipboard, lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, /// 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; } }; } }; const LookHoverWait = struct { col: u16, row: u16, pane: usize, serial: u32, frames: u16 = 0, body_hit: ?Mouse.BodyHit = null, tag_hit: ?TagHit = null, }; const LookHoverPreview = struct { col: u16, row: u16, pane: usize, serial: u32, sel: ?Pane.Sel, file_word: ?FileWordSpan = null, body_hit: ?Mouse.BodyHit = null, tag_hit: ?TagHit = null, }; const FileWordSpan = struct { row: i32, lo: i32, hi: i32, // exclusive }; const PdfWordPreview = if (pdf_enabled) struct { col: u16, row: u16, pane: usize, serial: u32, probe: panes.Pdf.WordProbe, fn deinit(preview: *@This(), gpa: std.mem.Allocator) void { preview.probe.deinit(gpa); preview.* = undefined; } } else void; const Drag = union(enum) { none, border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 }, border_h: struct { col: usize, top_idx: usize, cur_y: u16 }, move: struct { id: usize, cur_x: u16, cur_y: u16 }, /// `grab`: how far into the grip it was pressed, so the column's left /// edge lands at `cur_x - grab`. A release still on the grip moves nothing. column_move: struct { serial: u32, cur_x: u16, grab: u16 = 0 }, tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false, /// This gesture began over a usable native PDF raster. The payload is /// zero-bit when PDF support is absent. pdf: panes.Pdf.PointerDrag = .{}, }, fn button(drag: Drag) ?Mouse.Button { return switch (drag) { .none => null, .border_v, .border_h, .move, .column_move, .tag => config.select_button, .select => |selection| selection.button, }; } }; fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 { const lo = lx + config.MINW; const hi = lx +| lw +| rw -| config.MINW; return if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol; } fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16, tag_bottom: bool) u16 { const d: u16 = if (tag_bottom) 1 else 0; const lo = ay + BOX_H - 1 + d; const hi = (ay +| ah +| bh -| (BOX_H + 1)) +| d; return if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow; } test "a corner drag's two axes clamp independently" { // a 100-wide window split 50/50, and a 30-row column split 15/14 under the // topbar: the shape windowops.snap drives const lx: u16 = 0; const lw: u16 = 50; const rw: u16 = 50; const ay: u16 = TOPBAR_H; const ah: u16 = 15; const bh: u16 = 14; const row_lo: u16 = ay + BOX_H - 1; const row_hi: u16 = ay + ah + bh - (BOX_H + 1); // each axis alone, at both walls try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0)); try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999)); try std.testing.expectEqual(row_lo, clampBorderRow(ay, ah, bh, 0, false)); try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); // THE corner property: drag off the right edge while staying mid-height — // x sits at its wall, y still tracks the mouse row exactly try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999)); try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, false)); // and the mirror: pinned at the bottom, x still free try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40)); // both pinned at once is just both walls, not a stuck gesture try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0)); try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); try std.testing.expectEqual(row_lo + 1, clampBorderRow(ay, ah, bh, 0, true)); try std.testing.expectEqual(row_hi + 1, clampBorderRow(ay, ah, bh, 999, true)); try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, true)); // degenerate pairs pass through untouched, each on its own terms: a pane // pair with no room stays draggable even when the column pair has room try std.testing.expectEqual(@as(u16, 7), clampBorderCol(0, 5, 5, 7)); try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40)); try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false)); } fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 { const r = p.rects[p.col_panes[c][k]]; return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1; } fn cornerFixture(gpa: std.mem.Allocator) !*Pardes { const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30, .shells = 3 }); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); p.active = p.col_panes[1][0]; p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column p.sync(); return p; } fn nudgeRightSeam(p: *Pardes, delta: i32) u16 { const from = seamRowOf(p, 1, 0); const inside = p.col_x[1] + p.col_w[1] / 2; const to: u16 = @intCast(@as(i32, from) + delta); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to } }); p.sync(); return seamRowOf(p, 1, 0); } test "a v-handle press at the RIGHT column's seam drags both boundaries" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); const handle = p.col_x[0] + p.col_w[0] - 1; const right_seam = nudgeRightSeam(p, 3); try std.testing.expect(right_seam != seamRowOf(p, 0, 0)); const w0 = p.col_weight[0]; const v_left = p.panes[p.col_panes[0][0]].?.vweight; const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); try std.testing.expect(p.drag == .border_v); const corner = p.drag.border_v.corner orelse return error.NoCorner; try std.testing.expectEqual(@as(usize, 1), corner.col); try std.testing.expectEqual(@as(usize, 0), corner.idx); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = right_seam - 4 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = right_seam - 4 } }); p.sync(); // both halves committed: the column pair widened, and the RIGHT column's // pane pair reweighted — while the left column's panes stayed put try std.testing.expect(p.col_weight[0] > w0); try std.testing.expect(p.panes[p.col_panes[1][0]].?.vweight != v_right); try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); } test "a tie row still moves the LEFT column's pane pair only" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); // put column 1's seam exactly on column 0's, the row the eye reads as one // line across the whole window const left_seam = seamRowOf(p, 0, 0); _ = nudgeRightSeam(p, @as(i32, left_seam) - @as(i32, seamRowOf(p, 1, 0))); try std.testing.expectEqual(left_seam, seamRowOf(p, 1, 0)); const handle = p.col_x[0] + p.col_w[0] - 1; const v_left = p.panes[p.col_panes[0][0]].?.vweight; const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } }); const corner = p.drag.border_v.corner orelse return error.NoCorner; try std.testing.expectEqual(@as(usize, 0), corner.col); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = left_seam - 4 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = left_seam - 4 } }); p.sync(); try std.testing.expect(p.panes[p.col_panes[0][0]].?.vweight != v_left); try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a v-handle press at nobody's seam is still a plain edge drag" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); const right_seam = nudgeRightSeam(p, 3); const left_seam = seamRowOf(p, 0, 0); const handle = p.col_x[0] + p.col_w[0] - 1; var row: u16 = TOPBAR_H + 1; while (row == left_seam or row == right_seam) row += 1; const w0 = p.col_weight[0]; const v_left = p.panes[p.col_panes[0][0]].?.vweight; const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } }); try std.testing.expect(p.drag == .border_v); try std.testing.expect(p.drag.border_v.corner == null); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = row + 5 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = row + 5 } }); p.sync(); // exactly ONE boundary moved try std.testing.expect(p.col_weight[0] > w0); try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a RIGHT-column corner's two axes clamp independently" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); const right_seam = nudgeRightSeam(p, 3); const handle = p.col_x[0] + p.col_w[0] - 1; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); try std.testing.expectEqual(@as(usize, 1), (p.drag.border_v.corner orelse return error.NoCorner).col); // off the right edge at mid-height: x parks at its MINW wall, y is the mouse p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 999, .row = right_seam } }); try std.testing.expectEqual(@as(u16, 100 - config.MINW), p.drag.border_v.cur_x); try std.testing.expectEqual(right_seam, p.drag.border_v.cur_y); // and the mirror: below the bottom at mid-width. y parks, x tracks again p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = 999 } }); try std.testing.expectEqual(handle, p.drag.border_v.cur_x); const a = p.rects[p.col_panes[1][0]]; const b = p.rects[p.col_panes[1][1]]; try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } }); } 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); 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(); _ = 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(layout.Phase.moving, p.presentation.tracks[0].?.phase); try std.testing.expectEqual(layout.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..layout.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(); 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 = .vertical; _ = 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(); 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 = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); for (0..layout.Transition.vertical.frames()) |_| p.update(.tick); _ = 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(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = 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); p.applySettingBuiltin(config.Runtime.find("PanelZoom").?, null); try std.testing.expectEqual(layout.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}); p.applySettingBuiltin(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}); p.applySettingBuiltin(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 "pointer coordinates follow presented panel geometry" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const moving: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 }, }; p.acknowledgePanelPresentation(&.{moving}); // One shared physical-to-logical map feeds all pointer gestures. The // selected sample is 26.25% across and 35% down the presented rectangle. try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?); // Canonical cells covered only by the not-yet-arrived target are inert. try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null); // Unrelated screen space remains in the ordinary grid coordinate system. try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); p.acknowledgePanelPresentation(&.{layout.Track{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = .zoom, .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 }, .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 }, }}); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 40, 10) == null); // Tracks paint in pane-slot order inside a phase. The later slot is the // top quad and therefore owns an overlap, even though both are moving. const overlap = [_]layout.Track{ .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 }, .to = .{ .x = 0, .y = 2, .w = 20, .h = 4 }, }, .{ .serial = pane.serial + 1, .pane = 1, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 }, .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 }, } }; p.presentation.shown_tracks = @splat(null); p.presentation.shown_tracks[0] = overlap[0]; p.presentation.shown_tracks[1] = overlap[1]; try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?); } test "queued pointer input is inert until a changed layout is presented" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); // Establish that a real host has presented the boot layout. p.acknowledgePanelPresentation(&.{}); p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); try std.testing.expect(p.presentation.pending); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); var tracks: [MAX_PANES]layout.Track = undefined; var len: usize = 0; for (p.presentation.tracks) |maybe| if (maybe) |track| { tracks[len] = track; len += 1; }; p.acknowledgePanelPresentation(tracks[0..len]); try std.testing.expect(!p.presentation.pending); // Whether this particular opening sample exposes the chosen cell is the // transition's concern; it is no longer rejected merely as speculative. try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 99, 4) != 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(layout.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); var submitted: ?layout.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(layout.Phase.opening, first.phase); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 1, 2, true)); p.sync(); const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget; try std.testing.expectEqual(layout.Phase.opening, retargeted.phase); try std.testing.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 p.composeAsciiTransitions(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 p.composeAsciiTransitions(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 = layout.Transition.edges.frames() - 1; try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); } test "pointer rejects panel content cells which have not materialized" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.presentation.diffs, .unchanged); for (2..6) |row| { for (4..36) |col| { // Distances on both sides of frame five prove that finished ASCII // bytes are clickable while bytes still walking are not. p.presentation.diffs[row * p.screen_w + col] = .{ .ascii = .{ .from = ' ', .to = @intCast(' ' + (col - 4) % 12 + 1), } }; } } p.presentation.diff_state = .ready; for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; const track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = effect, .frame = frame, .from = target, .to = target, }; p.acknowledgePanelPresentation(&.{track}); var saw_visible = false; var saw_hidden = false; for (0..4) |relative_row| for (0..32) |relative_col| { const rcol: u16 = @intCast(relative_col); const rrow: u16 = @intCast(relative_row); const col: u16 = @intCast(4 + relative_col); const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { .dissolve => layout.dissolveRevealed( pane.serial, rcol, rrow, track.amount(), ), .ascii => switch (p.presentation.cellDiff(p.screen_w, p.screen_h, col, row)) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point); saw_visible = saw_visible or visible; saw_hidden = saw_hidden or !visible; }; try std.testing.expect(saw_visible); try std.testing.expect(saw_hidden); } } test "unchanged content cells remain clickable through data effects" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.presentation.diffs, .unchanged); p.presentation.diff_state = .ready; const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = effect, .from = box, .to = box, }}); try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?); } } test "stationary Look hover follows only acknowledged panel samples" { _ = config.look_preview_delay_frames orelse return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const rect = p.rects[0]; const target: layout.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 }, .to = target, }; p.acknowledgePanelPresentation(&.{track}); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 30, .row = 8 } }); const first = p.look_hover_wait orelse return error.MissingInitialHoverWait; // Advancing the producer-side track alone cannot move the semantic cell // under a stationary pointer. track.frame = track.effect.frames() - 1; p.presentation.tracks[0] = track; p.refreshLookHoverFromRaw(); try std.testing.expectEqual(first, p.look_hover_wait.?); // Once that exact sample is acknowledged, the same raw cell maps through // the new panel rectangle and the debounce starts over for the new target. p.acknowledgePanelPresentation(&.{track}); const moved = p.look_hover_wait orelse return error.MissingRemappedHoverWait; try std.testing.expect(first.col != moved.col or first.row != moved.row); try std.testing.expectEqual(@as(u16, 0), moved.frames); // Re-acknowledging the same semantic target is host repaint noise, not a // new hover, so it preserves the accumulated delay. p.look_hover_wait.?.frames = 3; p.acknowledgePanelPresentation(&.{track}); try std.testing.expectEqual(@as(u16, 3), p.look_hover_wait.?.frames); // A zero-sized opening sample covers its future target but exposes no // clickable content. Successful presentation therefore cancels the hover. track.phase = .opening; track.effect = .zoom; track.frame = 0; track.from = .{ .x = target.x + target.w * 0.5, .y = target.y + target.h * 0.5, }; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); track.frame = track.effect.frames() - 1; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.look_hover_wait != null); } test "held drag follows acknowledged panels and balances an invisible release" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const target = layout.panelBox(p.rects[0]); var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 50, .y = 5, .w = 20, .h = 8 }, .to = target, }; const raw_col: u16 = 55; const raw_row: u16 = 7; p.acknowledgePanelPresentation(&.{track}); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = raw_col, .row = raw_row, } }); try std.testing.expect(p.drag == .select); const first_col = pane.sel[sel_slot].c1; const first_row = pane.sel[sel_slot].r1; // Advancing producer state alone cannot move input. Only the sample the // host says it actually drew may remap the stationary held endpoint. track.frame = track.effect.frames() - 1; p.presentation.tracks[0] = track; try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1); p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(first_col != pane.sel[sel_slot].c1 or first_row != pane.sel[sel_slot].r1); const last_col = pane.sel[sel_slot].c1; const last_row = pane.sel[sel_slot].r1; track.phase = .opening; track.effect = .zoom; track.frame = 0; track.from = .{ .x = target.x + target.w * 0.5, .y = target.y + target.h * 0.5, }; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) == null); try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1); // An unrelated release cannot consume the left drag. Its own release is // nevertheless balanced at the retained endpoint while the cell is dark. p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = raw_col, .row = raw_row, } }); try std.testing.expect(p.drag == .select); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = raw_col, .row = raw_row, } }); try std.testing.expect(p.drag == .none); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); } 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, /// 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 (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, 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, 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, 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), col_tags: [MAX_COLS]TagLine = @splat(.{}), col_serial: [MAX_COLS]u32 = @splat(0), next_column_serial: u32 = 1, global_tag: TagLine = .{}, // derived each sync rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, presentation: layout.Presentation = .{}, active: usize = 0, 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 pane (by serial) that joined a column this step; `replaceStillborn` /// checks it once the step is over and nothing holds the column's panes. stillborn_joiner: ?u32 = null, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, locations_cache: @import("locations_cache.zig").Cache = .{}, tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, theme_file_path: 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), 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, haptic: HapticSlot = if (platform == .macos) .none else {}, drag: Drag = .none, 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, look_hover_wait: ?LookHoverWait = null, look_hover_preview: ?LookHoverPreview = null, pdf_hover_preview: if (pdf_enabled) ?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, leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, topbar_col: ?u16 = null, header_column: ?usize = null, header_insert: bool = false, header_anchor: ?u16 = null, header_drag: bool = false, header_scroll: 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, /// 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, 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_seq: u32 = 0, pipe_wait: ?selection_pipe.PendingPipe = null, 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, /// 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: a fixed ring, never allocated, never grown. See /// `logMessage` and the `Messages` builtin. messages: [limits.message_log]Messages.LoggedMessage = @splat(.{}), /// Next slot to write. `messages_len` saturates at the ring's size. messages_head: usize = 0, messages_len: usize = 0, in_q: [64]Event = undefined, in_head: usize = 0, in_len: usize = 0, yank: ?[]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, /// 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 = .{}, /// 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 fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .image_gpa = image_gpa, .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, .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 = &.{}; 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); 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(); }, .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].?.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. if (p.boot == .tty) { p.newScratchBelow(0); p.active = 0; } }, .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.applyStartupConfig(); 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; } pub fn deinit(p: *Pardes) void { p.cancelLookHover(); 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; if (p.yank) |y| gpa.free(y); 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); 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.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 { const copy = p.gpa.dupe(u8, path) catch return; if (p.last_dump) |old| 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 topbar line: the fixed builtins, plus `Restore ` once a dump /// exists — render and click dispatch must agree on this exact string fn topbar(p: *Pardes, buf: []u8) []const u8 { if (p.global_tag.custom) return p.global_tag.slice(""); if (p.last_dump) |d| return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; return config.topbar_str; } /// The workspace tag row's height: `TOPBAR_H` while the setting keeps /// it, 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 { return if (p.settings.workspace_tag) TOPBAR_H else 0; } pub fn columnBarHeight(p: *const Pardes) u16 { return if (p.settings.column_tags and p.screen_h >= 3) COLBAR_H else 0; } pub fn columnTag(p: *const Pardes, c: usize) []const u8 { return p.col_tags[c].slice("New Tty Find Grep Joincol Delcol"); } fn headerText(p: *Pardes) []const u8 { if (p.header_column) |c| return if (c < p.ncol) p.columnTag(c) else ""; var buf: [1200]u8 = undefined; return p.scratch.allocator().dupe(u8, p.topbar(&buf)) catch ""; } fn revealHeader(p: *Pardes) void { const cur = p.topbar_col orelse { p.header_scroll = 0; return; }; const text = p.headerText(); const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w); const col = panes.File.rawDisplayCol(text, @min(cur, text.len)); if (col < p.header_scroll) p.header_scroll = col; if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); var raw = panes.File.rawAtDisplay(text, p.header_scroll); p.header_scroll = panes.File.rawDisplayCol(text, raw); if (width > 0 and col >= p.header_scroll + width) { raw = modal.nextGrapheme(text, raw); p.header_scroll = panes.File.rawDisplayCol(text, raw); } } pub fn headerReplace(p: *Pardes, bytes: []const u8) void { const cur = p.topbar_col orelse return; const anchor = p.header_anchor orelse cur; const lo = @min(cur, anchor); const hi = @max(cur, anchor); const fallback = p.headerText(); const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag; if (line.replace(fallback, lo, hi, bytes)) { p.topbar_col = @intCast(lo + bytes.len); p.header_anchor = null; } } 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 { 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 } }); ctlfs.events.noteRetire(p, pane); // ...and so do bytes still queued for the pty it no longer has. p.dropPendingWrite(id); }; if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); 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 (p.lookHoverPane()) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) p.cancelLookHover(); } p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); panes.Terminal.deinitPendingCommand(pane); 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); for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); 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; // /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 > effect_path_cap) return error.PathTooLong; const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(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) !*Pane { const src = p.panes[from_id] orelse return error.MissingPane; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); np.cwd = .{ .inherited = src }; np.cur_pinned = true; return np; } /// The empty pane that stands in for one that closed, keeping its column /// alive and its directory. Unlike `newScratch` it inherits nothing: the /// pane it replaces is on its way out. fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane { const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content); np.cur_pinned = true; return np; } /// New: a scratch below the calling pane, in its column. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; const sf = layout.findPane(p, from_id) orelse return; const np = p.newScratch(from_id, free) catch return; layout.insert(p, sf.col, sf.idx + 1, free); layout.splitBelow(p, from_id, np); p.active = free; } /// Newcol: a scratch in a fresh column beside the calling pane. pub fn newScratchColumn(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; std.debug.assert(layout.splitColumn(p, from_id, free, false)); p.active = free; } 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; } fn noteLookSource(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; var w: usize = 0; for (p.look_src[0..p.n_look_src]) |s| { if (s == pane.serial) continue; p.look_src[w] = s; w += 1; } if (w == p.look_src.len) { std.mem.copyForwards(u32, p.look_src[0 .. w - 1], p.look_src[1..w]); w -= 1; } p.look_src[w] = pane.serial; p.n_look_src = w + 1; p.look_walk_owner = pane.serial; } pub fn armLookWalk(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file) |file| if (file.content.len == 0) return; p.look_walk_owner = pane.serial; } 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..]); } 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; const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", }; if (std.mem.eql(u8, cur, cwd)) return; pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); if (p.lookHoverPane() == id) p.cancelLookHover(); } fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; // A mounted shell sits behind sudo's process supervisor. Its OSC 133 // prompt marks identify input readiness; the launcher's executable is // not the shell executable used by the ordinary process heuristic. if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane); return !p.hostTtyTaken(id); } pub fn shellBin(p: *const Pardes) []const u8 { const selected = p.settings.shell.requested.get(); return if (selected.len == 0) config.default_shell 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); // a respawn replaced whatever ran pane.fs.unmarked = !prompt_marks; }; 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 Config 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(); p.invalidateLookHover(id); const applied = p.applyWatchedFileChanged(id, bytes); _ = p.scratch.reset(.retain_capacity); p.sync(); return applied; } pub fn invalidateLookHover(p: *Pardes, id: usize) void { if (p.lookHoverPane() != id) return; p.raw_hover_intent = false; p.cancelLookHover(); } 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; } } /// 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 hostTtyTaken(p: *const Pardes, id: usize) bool { const f = p.host.vtable.tty_taken 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, "save", error.OutOfMemory); } pub fn saveFailed(p: *Pardes, id: u8, what: []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; }; p.reportError(id, what, err); } /// 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) { .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, 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), .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), .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; f.saved_revision = f.revision; p.hostWriteFile(sf.pane, f.path, f.content); const saved_pane = p.panes[sf.pane] orelse return; if (saved_pane.serial != serial) return; ctlfs.events.noteLog(p, .save, saved_pane); const saved = if (saved_pane.file) |*file| file else return; if (saved.saved_revision != saved.revision or !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| return p.hostWriteFile(st.pane, st.path.slice(), f.content); if (!pane.isTerminal()) return; const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); p.hostWriteFile(st.pane, st.path.slice(), text); }, .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.yank 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| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0); 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. if (!p.needs_frame and !p.animationActive()) return; _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); // 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(req)) return ctlfs.handle(p, req); p.needs_frame = true; p.raw_hover_intent = false; p.cancelLookHover(); const reply = ctlfs.handle(p, req); // 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(); ctlfs.events.announce(p); return reply; } pub fn update(p: *Pardes, ev: Event) void { defer p.replaceStillborn(); // 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); // 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; p.cancelLookHover(); }, .output => |o| if (p.lookHoverPane() == o.pane) { p.raw_hover_intent = false; p.cancelLookHover(); }, .file_changed => |changed| if (p.lookHoverPane() == changed.pane) { p.raw_hover_intent = false; p.cancelLookHover(); }, .pdf_scroll => |scroll| if (p.lookHoverPane() == scroll.pane) { p.raw_hover_intent = false; p.cancelLookHover(); }, else => { p.raw_hover_intent = false; p.cancelLookHover(); }, } switch (ev) { .key, .mouse => { for (p.panes) |slot| { if (slot) |pane| Messages.dismissMessage(p, pane); } p.clip_pending = null; }, .tick => Messages.advanceMessages(p), 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; ctlfs.events.notePtyOutput(p, o.pane, o.bytes); panes.Terminal.feedOutput(p, pane, o.bytes); ctlfs.pty.noteMarks(p, o.pane, pane); }, .eof => |e| p.removePane(e.pane, null) catch |err| { if (p.panes[e.pane]) |pane| pane.mode = .normal; p.reportError(e.pane, "terminal exited; Del retries close", err); }, .lsp_resp => |r| p.lspResponse(r.id, r.rows), .pipe_resp => |r| selection_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 => {}, }; p.handleMouse(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, .pointer_leave => { p.pointer_inside = false; p.pointer_body_hit = null; p.pointer_tag_hit = null; }, .tick => { p.chrome_animation.advance(); p.presentation.advance(); p.advanceLookHover(); }, } _ = 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 = p.tagText(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 {}; } } fn multiOnce(p: *Pardes) bool { if (!p.multi_on) return true; p.multi_stop = true; return p.multi_first; } // ---- tag + selection text (chord sources) ---- pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count }, ); if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { const name = if (pane.tag_name_len) |len| pane.tag_name[0..len] else f.path; if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); return arena.dupe(u8, name); } return arena.dupe(u8, pane.cwdSlice()); } /// the editable tail: the user's edited buffer once touched, else defaults fn curTail(pane: *Pane) []const u8 { if (pane.tag_init) return pane.tagSlice(); return defaultTail(pane); } fn defaultTail(pane: *const Pane) []const u8 { if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; if (pane.file) |file| if (file.output) |out| if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; if (panes.File.supportsContext(pane)) return context_pane_tail; if (pane.file != null) return file_pane_tail; if (pane.isTerminal()) return terminal_pane_tail; return pane_tail; } 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; } pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); const out = try arena.alloc(u8, prefix.len + tail.len); @memcpy(out[0..prefix.len], prefix); @memcpy(out[prefix.len..], tail); return out; } fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { if (pane.isTerminal()) { var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); while (words.next()) |word| { if (!std.mem.eql(u8, word, "Tty")) continue; const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); return .{ .lo = lo, .hi = lo + word.len }; } return null; } var start: usize = 0; const name = if (pane.file) |file| if (pane.tag_name_len) |len| pane.tag_name[0..len] else file.path else if (pane.image) |state| blk: { // Image tags put renderer controls before the path. start = prefix_len -| state.path.len; break :blk state.path; } else if (comptime pdf_enabled) blk: { break :blk if (pane.pdf) |state| state.path else return null; } else return null; const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; if (basename == name.len) return null; return .{ .lo = start + basename, .hi = start + name.len }; } pub fn seedTail(_: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); if (tail.len > pane.tag_tail.len) return; @memcpy(pane.tag_tail[0..tail.len], tail); pane.tag_tail_len = tail.len; pane.tag_init = true; } pub fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col; const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); p.seedTail(pane); if (!pane.tag_init) return; if (!pane.tag_edit) pane.tag_mode = pane.mode; pane.tag_edit = true; pane.tag_sel = false; // a one-line tag has no use for normal mode: always insert pane.mode = .insert; pane.normal.clear(); const end = edit0 + @as(i32, @intCast(pane.tag_tail_len)); const tail = pane.tagSlice(); const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); if (col < 0) { const text = p.tagText(p.scratch.allocator(), pane) catch return; if (pane.tag_saved_col) |saved| { pane.tag_col = @intCast(modal.graphemeStart(text, @min(saved, text.len))); } else { pane.tag_col = @intCast(@min(edit0 + lead, end)); } if (pane.tag_saved_col == null) if (p.paneIdOf(pane)) |id| { const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET); // Reveal the suffix together with its commands, not merely the // first command letter at the viewport's far right edge. pane.tag_scroll = @intCast(panes.File.displayWidth(text) + 1 -| width); }; } else { const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(col)) + pane.tag_scroll))); } } fn exitTagEdit(pane: *Pane) void { if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col; if (pane.isTerminal()) { if (pane.tag_edit) pane.mode = pane.tag_mode; } else pane.mode = .normal; pane.tag_edit = false; pane.tag_sel = false; pane.tag_name_len = null; pane.tag_scroll = 0; pane.prompt = .none; // an abandoned prompt stays as tag text pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } pub fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { const a: usize = pane.tag_anchor; const c: usize = pane.tag_col; return .{ .lo = @min(a, c), .hi = @max(a, c) }; } fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 { const text = p.tagText(p.scratch.allocator(), pane) catch return null; if (pane.tag_sel) { const b = tagSelBounds(pane); const hi = modal.nextGrapheme(text, b.hi); return if (hi > b.lo) text[b.lo..hi] else null; } const b = config.wordBounds(text, @min(@as(usize, pane.tag_col), text.len)); return if (b.hi > b.lo) text[b.lo..b.hi] else null; } fn tagNameEnd(pane: *const Pane) ?usize { const f = pane.file orelse return null; if (!panes.Output.fileTraits(f.output).saves or f.path.len > pane.tag_name.len) return null; return if (pane.tag_name_len) |len| len else f.path.len; } /// Commit a staged buffer name, never a disk rename or write. Keep the /// draft visible on failure so the user can correct it or cancel with Esc. pub fn commitTagName(p: *Pardes, pane: *Pane) bool { const len = pane.tag_name_len orelse return true; const id = p.paneIdOf(pane) orelse return false; const name = pane.tag_name[0..len]; if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) { p.reportError(id, "name", error.InvalidName); return false; } for (name) |byte| if (byte < ' ') { p.reportError(id, "name", error.InvalidName); return false; }; const old_prefix = (p.tagPrefix(pane) catch return false).len; const tail_col = @as(usize, pane.tag_col) -| old_prefix; const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| { p.reportError(id, "name", err); return false; }; ctlfs.pane.nameBuffer(p, id, full, true) catch |err| { p.reportError(id, "name", err); return false; }; pane.tag_name_len = null; const prefix = (p.tagPrefix(pane) catch return false).len; pane.tag_col = @intCast(prefix + @min(tail_col, pane.tag_tail_len)); pane.tag_sel = false; return true; } /// Replace one range in the rendered tag. The file name and command tail /// retain separate identities even when a selection crosses their boundary. pub fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool { const prefix_len = (p.tagPrefix(pane) catch return false).len; const end = prefix_len + pane.tag_tail_len; const lo = @min(start, end); const hi = @max(lo, @min(finish, end)); const name_end = tagNameEnd(pane); const editing_name = if (name_end) |n| lo <= n else false; if (!editing_name and lo < prefix_len) return false; var bytes: [limits.max_tag_tail]u8 = undefined; var count: usize = 0; for (input) |byte| { if (byte == '\n' or byte == '\r') continue; if (count == bytes.len) return false; bytes[count] = byte; count += 1; } const tail_lo = lo -| prefix_len; const tail_hi = hi -| prefix_len; const tail_insert = if (editing_name) 0 else count; const tail_len = pane.tag_tail_len - (tail_hi - tail_lo) + tail_insert; if (tail_len > pane.tag_tail.len) return false; if (editing_name) { const n = name_end.?; const name_hi = @min(hi, n); const name_len = n - (name_hi - lo) + count; if (name_len > pane.tag_name.len) return false; if (pane.tag_name_len == null) @memcpy(pane.tag_name[0..n], pane.file.?.path); if (count > name_hi - lo) std.mem.copyBackwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]) else std.mem.copyForwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]); @memcpy(pane.tag_name[lo..][0..count], bytes[0..count]); pane.tag_name_len = @intCast(name_len); } if (tail_insert > tail_hi - tail_lo) std.mem.copyBackwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]) else std.mem.copyForwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]); if (!editing_name) @memcpy(pane.tag_tail[tail_lo..][0..count], bytes[0..count]); pane.tag_tail_len = tail_len; pane.tag_col = @intCast(lo + count); pane.tag_sel = false; return true; } fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.escape)) { exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it return; } // the acme chords on the selection or the word at the cursor if (hit(key, config.look_key) or hit(key, config.exec_key)) { if (pane.tag_name_len != null) { const in_name = pane.tag_col < (p.tagPrefix(pane) catch return).len; if (!p.commitTagName(pane)) return; if (hit(key, config.look_key) or in_name) { exitTagEdit(pane); return; } } if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); } return; } const text = p.tagText(p.scratch.allocator(), pane) catch return; const end = text.len; if (key.text.len > 0) { const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; _ = p.replaceTagText(pane, range.lo, hi, key.text); return; } switch (key.cp) { Key.backspace, Key.delete => { if (pane.tag_sel) { const range = tagSelBounds(pane); _ = p.replaceTagText(pane, range.lo, modal.nextGrapheme(text, range.hi), ""); } else if (key.cp == Key.backspace and pane.tag_col > 0) { _ = p.replaceTagText(pane, modal.prevGrapheme(text, pane.tag_col), pane.tag_col, ""); } else if (key.cp == Key.delete and pane.tag_col < end) { _ = p.replaceTagText(pane, pane.tag_col, modal.nextGrapheme(text, pane.tag_col), ""); } }, Key.left => if (pane.tag_col > 0) { pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col)); }, Key.right => if (pane.tag_col < end) { pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col)); }, Key.home => pane.tag_col = 0, Key.end => pane.tag_col = @intCast(end), else => {}, } } /// write a helix range back onto the tag cursor + selection: the rendered /// tag's one-line mirror of setPaneRange. fn setTagRange(pane: *Pane, text: []const u8, r: modal.Selection) void { const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); pane.tag_col = @intCast(modal.selectionCursor(text, r)); pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi)); } fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void { const text = p.tagText(p.scratch.allocator(), pane) catch return; const cur: usize = @min(@as(usize, pane.tag_col), text.len); // Esc abandons the command line: back to the body, tail kept as text if (hit(key, config.escape) or hit(key, &.{.{ .cp = 'j' }})) return exitTagEdit(pane); if (hit(key, config.look_key) or hit(key, config.exec_key)) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; const txt = p.tagChordText(pane) orelse return; const id = p.active; const body_mode = pane.tag_mode; exitTagEdit(pane); // leave the tag FIRST: Del frees the pane pane.mode = body_mode; p.runBuiltin(cmd, id, "", txt); return; } if (hit(key, config.tag_yank)) { if (p.tagChordText(pane)) |txt| edit.setClipboard(p, txt); return; } if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { const edit0: usize = if (tagNameEnd(pane) != null) 0 else (p.tagPrefix(pane) catch return).len; pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append)) modal.nextGrapheme(text, cur) else if (hit(key, config.insert_line_start)) 0 else if (hit(key, config.insert_line_end)) text.len else cur)); pane.tag_sel = false; pane.mode = .insert; return; } const dir: ?Builtin = for (config.window_keys) |wk| { var letter = wk.letter; letter.cp = std.ascii.toUpper(@intCast(letter.cp)); if (hit(key, &.{letter})) break wk.cmd; } else null; if (dir) |d| { const from = p.active; p.runBuiltin(d, from, "", null); if (p.active == from) { if (d == .Up) { exitTagEdit(pane); p.enterHeader(if (p.columnBarHeight() > 0) (layout.findPane(p, from) orelse return).col else null); } return; } const dest = p.panes[p.active] orelse return; exitTagEdit(pane); // the pane we left is fully out of its tag... p.enterTagEdit(dest, -1); if (dest.tag_edit) dest.mode = .normal; return; } if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); } fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.Selection { const target: ?usize = if (hit(key, config.line_move_left)) modal.prevGrapheme(text, cur) else if (hit(key, config.line_move_right)) modal.nextGrapheme(text, cur) else if (hit(key, config.line_start)) 0 else if (hit(key, config.line_end)) modal.prevGrapheme(text, text.len) else if (hit(key, config.line_first_nonws)) modal.firstNonWs(text) else null; if (target) |t| return .{ .anchor = t, .head = t }; const wt: ?modal.WordTarget = if (hit(key, config.next_word_start)) .next_word_start else if (hit(key, config.prev_word_start)) .prev_word_start else if (hit(key, config.next_word_end)) .next_word_end else if (hit(key, config.next_long_word_start)) .next_long_word_start else if (hit(key, config.prev_long_word_start)) .prev_long_word_start else if (hit(key, config.next_long_word_end)) .next_long_word_end else null; const t = wt orelse return null; return modal.moveWord(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); } fn rememberHeader(p: *Pardes) void { const col = p.topbar_col orelse return; const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag; line.saved_col = col; } pub fn exitHeader(p: *Pardes) void { p.rememberHeader(); p.topbar_col = null; p.header_column = null; p.header_anchor = null; p.header_drag = false; p.header_scroll = 0; } fn enterHeader(p: *Pardes, column: ?usize) void { p.rememberHeader(); p.header_column = column; const line = if (column) |c| &p.col_tags[c] else &p.global_tag; const text = p.headerText(); p.topbar_col = @intCast(modal.graphemeStart(text, @min(line.saved_col orelse 0, text.len))); p.header_insert = false; p.header_anchor = null; p.header_drag = false; p.header_scroll = 0; } fn topbarKey(p: *Pardes, key: Key) void { const bar = p.headerText(); // the line GROWS a `Restore ` word the moment a dump lands, so // the column is clamped against the live line on every use const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len); if (hit(key, config.escape) or (!p.header_insert and hit(key, &.{.{ .cp = 'j' }}))) { p.exitHeader(); return; } if (hit(key, config.look_key) or hit(key, config.exec_key)) { const a = p.header_anchor orelse @as(u16, @intCast(cur)); const word = if (a != cur) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, cur); p.exitHeader(); if (word.len > 0) { if (!p.header_insert) { // Preserve the workspace tag's keyboard command semantics. _ = p.execute(p.active, word); } else p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", word); } return; } if (p.header_insert) { if (key.ctrl and key.cp == 'v') { p.emit(.read_clipboard); return; } if (key.ctrl and (key.cp == 'c' or key.cp == 'x')) { const anchor = p.header_anchor orelse @as(u16, @intCast(cur)); if (anchor != cur) { edit.setClipboard(p, bar[@min(anchor, cur)..@max(anchor, cur)]); if (key.cp == 'x') p.headerReplace(""); } return; } if (key.text.len > 0 and !key.ctrl and !key.alt) { p.headerReplace(key.text); return; } if (key.cp == Key.backspace or key.cp == Key.delete) { if (p.header_anchor == null or p.header_anchor.? == cur) p.header_anchor = @intCast(if (key.cp == Key.backspace) modal.prevGrapheme(bar, cur) else modal.nextGrapheme(bar, cur)); p.headerReplace(""); return; } const next: ?usize = switch (key.cp) { Key.left => modal.prevGrapheme(bar, cur), Key.right => modal.nextGrapheme(bar, cur), Key.home => 0, Key.end => bar.len, else => null, }; if (next) |at| { if (key.shift) { if (p.header_anchor == null) p.header_anchor = @intCast(cur); } else p.header_anchor = null; p.topbar_col = @intCast(at); } return; } if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { p.header_insert = true; p.topbar_col = @intCast(if (hit(key, config.insert_line_end)) bar.len else if (hit(key, config.insert_line_start)) 0 else if (hit(key, config.append)) modal.nextGrapheme(bar, cur) else cur); return; } if (hit(key, config.topbar_down)) { if (p.header_column == null and p.columnBarHeight() > 0) { p.enterHeader(if (layout.findPane(p, p.active)) |f| f.col else 0); return; } var dest: ?usize = null; for (p.panes, 0..) |slot, i| { if (slot == null or p.rects[i].y != p.topBarHeight() + p.columnBarHeight()) continue; if (p.header_column) |c| if (p.rects[i].x != p.col_x[c]) continue; if (i == p.active) { dest = i; break; } if (dest == null or p.rects[i].x < p.rects[dest.?].x) dest = i; } const d = dest orelse return; const pane = p.panes[d].?; p.exitHeader(); p.active = d; p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; return; } if (hit(key, config.topbar_up)) { p.enterHeader(null); return; } const col: ?usize = if (hit(key, config.topbar_left)) modal.prevGrapheme(bar, cur) else if (hit(key, config.topbar_right)) modal.nextGrapheme(bar, cur) else if (lineMotion(bar, cur, key)) |r| modal.selectionCursor(bar, r) else null; // never past the last cell: there is nothing to append up here, so the // block cursor stays ON a character (which is where `$` already lands) if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); } const ExpandedWord = struct { lo: usize, hi: usize }; fn expandedWord(line: []const u8, col: usize) ?ExpandedWord { if (col >= line.len) return null; const b = config.wordBounds(line, col); const command_run = b.hi > b.lo and config.commandWord(line[b.lo..b.hi]) != null; if (!config.isFileChar(line[col]) and !command_run) return null; if (b.hi <= b.lo) return null; return .{ .lo = b.lo, .hi = b.hi }; } /// acme: a no-drag middle/right click expands to the word under it — /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling) fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { var sl = at; if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; const text = edit.paneText(p, pane) catch return null; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; while (it.next()) |line| : (v += 1) { if (v != sl.r0) continue; const display_col: usize = @intCast(@max(0, sl.c0)); const col = panes.File.renderedLineByteCol(pane, v, line, display_col); const b = expandedWord(line, col) orelse return null; sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo)); sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1); return sl; } return null; } /// the word under the modal cursor as a pane-local selection (paneText /// coords: row 0 is the tag; file panes carry the line-number prefix) fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { const w = pane.wrapRow(pane.cur_row, pane.cur_col); const vrow = w.row + @as(i32, BOX_H); const vcol = if (pane.file != null) panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane)) else blk: { const lines = edit.paneCursorLines(p, pane) catch break :blk pane.cur_col; const local = pane.cur_row; if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col; break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); }; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { if (pane.nsel == 0) return false; const text = edit.flatSurface(p, pane) catch return false; var ranges: [Pane.max_selections]modal.Selection = undefined; const got = pane.ranges(text, 0, &ranges); if (got.n < 2) return false; var texts: [Pane.max_selections][]u8 = undefined; var made: usize = 0; defer for (texts[0..made]) |t| p.gpa.free(t); for (ranges[0..got.n]) |range| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > text.len) break; texts[made] = p.gpa.dupe(u8, text[lo..hi]) catch break; made += 1; } // All or nothing, like the pipe: half a chord is not a chord. if (made != got.n) return false; const id = p.active; const serial = pane.serial; pane.vsel.active = false; pane.msel.active = false; pane.select = false; pane.nsel = 0; // the chord consumed them, exactly as it consumes one var primary_active: ?usize = null; for (texts[0..made], 0..) |txt, i| { p.runBuiltin(cmd, id, "", txt); if (i == got.pri) primary_active = p.active; const still = p.panes[id] orelse break; if (still.serial != serial) break; } if (primary_active) |a| if (p.panes[a] != null) { p.active = a; }; return true; } const PointerOperand = struct { /// Absolute body position corresponding to the pointed screen cell. row: i32, col: i32, /// Exactly what a no-drag middle/right click will dispatch. text: ?[]const u8 = null, /// What hover paints. Null names the pane's live modal selection. preview: ?Pane.Sel = null, /// File words stay logical so a soft-wrapped operand is not cut into /// unrelated rendered fragments. Null for tags and non-file panes. file_word: ?FileWordSpan = null, /// A newly expanded word is installed in the gesture's button slot; /// an existing selection is only borrowed and must not replace it. expanded: ?Pane.Sel = null, }; fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { const visible = clicked.r0 - @as(i32, BOX_H); const wrapped = pane.wrapAt(visible); const raw = pane.isTerminal() and pane.mode == .tty and clicked.r0 >= BOX_H; const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line; const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else ""; const col = if (raw) @as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0))))) else if (clicked.r0 >= BOX_H) edit.paneByteAtDisplay( p, pane, wrapped.line, wrapped.at, clicked.c0 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ) else clicked.c0; var result: PointerOperand = .{ .row = row, .col = col }; const kept = pane.sel[sel_slot]; const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: { if (clicked.r0 < BOX_H) break :inside false; if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false; const source_col: usize = @intCast(@max(0, col)); for (rows) |span| { const target = if (span.raw_terminal) (if (raw) span.row else pane.surfRow(span.row)) else (if (raw) pane.gridRow(span.row) else span.row); var lo = span.lo; var hi = span.hi; if (span.raw_terminal and !raw) { lo -|= span.prompt_bytes; hi -|= span.prompt_bytes; } else if (!span.raw_terminal and raw) { const prefix = panes.Terminal.promptPrefixBytes(pane, row, raw_line); lo += prefix; hi += prefix; } if (target == row and source_col >= lo and source_col < hi) break :inside true; } break :inside false; } else edit.spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1); if (kept.state == .done and inside_kept) { result.text = edit.selectionText(p, pane, kept) catch null; result.preview = kept; return result; } if (pane.vsel.active and pane.vsel.explicit and edit.spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col)) { result.text = edit.currentSelText(p, pane); return result; } if (pane.msel.active and row >= @min(pane.msel.r0, pane.msel.r1) and row <= @max(pane.msel.r0, pane.msel.r1)) { result.text = edit.currentSelText(p, pane); return result; } if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { const line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); const b = expandedWord(line, source_col) orelse return result; const lo = std.math.cast(i32, b.lo) orelse return result; const hi = std.math.cast(i32, b.hi) orelse return result; result.text = line[b.lo..b.hi]; result.file_word = .{ .row = row, .lo = lo, .hi = hi }; var expanded = clicked; const first = pane.wrapRow(row, lo); const last = pane.wrapRow(row, hi - 1); if (first.row >= 0 and last.row >= 0) { expanded.r0 = first.row + @as(i32, BOX_H); expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); expanded.r1 = last.row + @as(i32, BOX_H); expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); } result.preview = expanded; result.expanded = expanded; return result; } const expanded = p.expandedSel(pane, clicked) orelse return result; result.text = edit.selectionText(p, pane, expanded) catch null; if (result.text == null or result.text.?.len == 0) return result; result.preview = expanded; result.expanded = expanded; return result; } fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { var k: usize = 0; while (k < p.panes.len + 2) : (k += 1) { const i = switch (k) { 0 => first, 1 => p.active, else => k - 2, }; if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; const t = p.panes[i] orelse continue; if (comptime pdf_enabled) if (p.native_images) if (t.pdf) |pv| { if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text; }; if (t.sel[sel_slot].state == .done) { if (edit.selectionText(p, t, t.sel[sel_slot]) catch null) |tx| return tx; } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { if (edit.currentSelText(p, t)) |tx| return tx; } } return null; } fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n"); if (a.len == 0) return txt; const t = std.mem.trim(u8, txt, " \t\r\n"); return std.fmt.allocPrint(p.scratch.allocator(), "{s} {s}", .{ t, a }) catch txt; } /// 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 = if (pane.tag_edit) pane.tag_mode else pane.mode; p.active = id; p.exitHeader(); if (pane.tag_edit) exitTagEdit(pane); pane.mode = previous; switch (previous) { .tty => { pane.mode = .normal; pane.normal.clear(); }, .normal => edit.enterInsert(p, pane, .at, 1), .insert => { edit.exitInsert(p, pane); 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; p.exitHeader(); if (pane.tag_edit) exitTagEdit(pane); if (pane.mode == .tty) { pane.mode = .normal; pane.normal.clear(); } else panes.Terminal.enterTty(p, id); } fn handleKey(p: *Pardes, key: Key) void { // 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; if (asked.prompt != .del_side) continue; asked.prompt = .none; if (hit(key, config.del_above)) p.runBuiltin(.Del, id, "", "k"); if (hit(key, config.del_below)) p.runBuiltin(.Del, id, "", "j"); return; } if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; if (pane.isTerminal() and pane.mode == .tty and !pane.tag_edit) { 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 p.runBuiltin(.Last, p.active, "", null); if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active)) return p.runBuiltin(.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.yank) |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 p.runBuiltin(wk.cmd, p.active, "", null); } return; } // insert mode owns Ctrl-w (delete word backward, helix); the focus // prefix applies outside raw tty mode if (hit(key, config.window_prefix) and pane.mode != .insert) { 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; const nt = p.newShell(free, "") catch return; nt.greet = true; const src = layout.splitParent(p, p.active); const f = layout.findPane(p, src).?; layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; p.stillborn_joiner = nt.serial; return; } for (config.jump_keys) |jk| { if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { 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 (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) { const submit = hit(key, config.search_submit); switch (pane.prompt) { .pipe => |pipe| { if (submit) selection_pipe.submitPipe(p, p.active); pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); }, .save => |at| { if (submit) p.submitSave(p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); }, .search => |at| { if (submit) p.submitSearch(p.active) else if (selRegexArmed(pane)) |_| p.applySelRegex(pane, "", false); pane.tag_tail_len = @min(at, pane.tag_tail_len); }, .none, .del_side => unreachable, } exitTagEdit(pane); pane.mode = .normal; pane.normal.clear(); return; } if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); const pn = p.panes[p.active] orelse return; if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); return; } if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; edit.pinPaneCursor(p, pane); const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { if (p.chordEachSel(pane, cmd)) return; if (edit.currentSelText(p, pane)) |txt| { pane.vsel.active = false; pane.msel.active = false; pane.select = false; p.runBuiltin(cmd, p.active, "", txt); return; } } const sel = p.expandedSel(pane, p.cursorWordSel(pane)) orelse return; const word = edit.selectionText(p, pane, sel) catch return; p.runBuiltin(cmd, p.active, "", word); return; } if (pane.mode == .normal and pane.hasPdf()) return p.handlePdfNormal(pane, key); switch (pane.mode) { .normal => { p.handleNormal(pane, key); }, .insert => { if (hit(key, config.escape)) return edit.exitInsert(p, pane); edit.handleInsert(p, pane, key); }, .tty => panes.Terminal.forwardKey(p, p.active, key), } } 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 p.runBuiltin(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 ---- fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { const text = edit.flatSurface(p, pane) catch return; const got = pane.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; p.startPrompt(pane, .{ .search = if (split) config.split_marker else config.select_marker }); } pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { const prompt_at = switch (pane.prompt) { .search => |at| at, else => return null, }; if (pane.nsel_snap == 0) return null; const tail = pane.tagSlice(); const armed = tail[@min(prompt_at, tail.len)..]; const split = std.mem.startsWith(u8, armed, config.split_marker); if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; return .{ .pat = armed[slash + 1 ..], .split = split }; } pub fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { const text = edit.flatSurface(p, pane) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Pane.max_selections]modal.Selection = undefined; var m: usize = 0; if (pat.len > 0) if (mvzr.compile(pat)) |re| { var hay_all = text; if (for (pat) |c| { if (std.ascii.isUpper(c)) break false; } else true) { const low = p.scratch.allocator().dupe(u8, text) catch return; for (low) |*c| c.* = std.ascii.toLower(c.*); hay_all = low; } for (snap) |r| { const from = @min(r.anchor, r.head); const to = @min(@max(r.anchor, r.head), text.len); if (from >= to) continue; const hay = hay_all[from..to]; var at: usize = 0; var piece = from; // split: where the next piece begins while (at < hay.len and m < Pane.max_selections) { const hit_at = re.matchPos(at, hay) orelse break; if (split) { out[m] = .{ .anchor = piece, .head = from + hit_at.start }; m += 1; piece = from + hit_at.end; } else if (from + hit_at.start != to) { out[m] = .{ .anchor = from + hit_at.start, .head = from + hit_at.end }; m += 1; } // an empty match would otherwise never advance at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; } if (split and piece < to and m < Pane.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } } }; if (m == 0) { // the text CAN move under an armed prompt (a tag chord runs a // builtin), and these are raw offsets into the surface as it was for (snap) |*r| { r.anchor = @min(r.anchor, text.len); r.head = @min(r.head, text.len); } return pane.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } const KeyState = struct { mode: Pane.Mode, select: bool, normal: modal.Normal.State, find_op: u8, find_ch: u21, append_at: @FieldType(Pane, "append_at"), fn of(pane: *Pane) KeyState { return .{ .mode = pane.mode, .select = pane.select, .normal = pane.normal, .find_op = pane.find_op, .find_ch = pane.find_ch, .append_at = pane.append_at, }; } fn into(s: KeyState, pane: *Pane) void { pane.mode = s.mode; pane.select = s.select; pane.normal = s.normal; pane.find_op = s.find_op; pane.find_ch = s.find_ch; pane.append_at = s.append_at; } }; /// what a replayed key does at each cursor const Replay = union(enum) { normal: modal.Normal.Action, insert: Key, }; pub fn replaySels(p: *Pardes, pane: *Pane, what: Replay) void { const id = p.active; const serial = pane.serial; // the whole selection in pane coordinates, document order. pane.sels // is already ordered, so this only slots the primary into place. var list: [Pane.max_selections]Pane.SelRange = undefined; var n: usize = 0; var pri: usize = 0; const prim: Pane.SelRange = .{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, .sticky = pane.sticky_col, }; const pr = Pane.selStart(prim); var placed = false; for (pane.sels[0..pane.nsel]) |s| { const sr = Pane.selStart(s); if (!placed and (pr.row < sr.row or (pr.row == sr.row and pr.col <= sr.col))) { pri = n; list[n] = prim; n += 1; placed = true; } list[n] = s; n += 1; } if (!placed) { pri = n; list[n] = prim; n += 1; } const saved = KeyState.of(pane); const explicit = pane.vsel.explicit; var after = saved; var after_expl = explicit; // each pass's result: cursor and anchor cells as distances from the // end of the surface text, plus the range's own j/k goal column var res: [Pane.max_selections]struct { cur: usize, anc: usize, sticky: i32 } = undefined; p.multi_on = true; p.multi_stop = false; var passes: usize = 0; var i: usize = n; while (i > 0) { i -= 1; p.multi_first = passes == 0; passes += 1; saved.into(pane); pane.nsel = 0; // the handler underneath sees ONE selection pane.cur_row = list[i].row; pane.cur_col = list[i].col; pane.vsel = .{ .active = list[i].arow != list[i].row or list[i].acol != list[i].col or pane.select, .row = list[i].arow, .col = list[i].acol, .explicit = explicit, }; pane.msel.active = false; pane.sticky_col = list[i].sticky; switch (what) { .normal => |normal_action| p.executeNormalAction(pane, normal_action), .insert => |insert_key| edit.insertKey(p, pane, insert_key), } if (p.multi_stop) break; if (i == pri) { after = KeyState.of(pane); after_expl = pane.vsel.explicit; } const text = edit.flatSurface(p, pane) catch { p.multi_stop = true; break; }; const co = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const ao = if (pane.vsel.active) modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) else co; res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = pane.sticky_col }; } p.multi_on = false; p.multi_first = false; if (p.multi_stop) { p.multi_stop = false; const pn = p.panes[id] orelse return; if (pn.serial == serial) pn.nsel = 0; return; } const text = edit.flatSurface(p, pane) catch return; var rs: [Pane.max_selections]modal.Selection = undefined; var st: [Pane.max_selections]i32 = undefined; for (res[0..n], 0..) |r, k| { rs[k] = Pane.cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); st[k] = r.sticky; } pane.setRanges(text, rs[0..n], st[0..n], pri, after_expl); after.into(pane); pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass } fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, }; if (pane.nsel == 0 or semantic.scope() == .once) return p.executeNormalAction(pane, semantic); p.replaySels(pane, .{ .normal = semantic }); } fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) void { const raw_selection = pane.rawPointerText(pane.sel[sel_slot]) != null; const before = .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode }; defer if (raw_selection and !std.meta.eql(before, .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode })) { pane.clearPointerSelection(sel_slot); pane.sel[sel_slot].state = .none; }; if (raw_selection and semantic == .edit and semantic.edit.kind == .yank) return edit.normalYank(p, pane); edit.pinPaneCursor(p, pane); const text = edit.flatSurface(p, pane) catch return; const range = pane.primaryRange(text, 0); const cur = modal.selectionCursor(text, range); switch (semantic) { .escape => { pane.select = false; return p.runBuiltin(.Last, p.active, "", null); }, .goto => |go| switch (go.target) { .file_start => { const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Pane.maxLine(text)) else 0; return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, .last_line => return pane.pointMove(text, range, modal.lineStartOffset(text, Pane.maxLine(text))), .line_start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .line_end => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pane.pointMove(text, range, ls + nw); }, .line_down => return pane.visualMove(text, range, true, go.count, edit.paneWrapWidth(p, pane)), .line_up => return pane.visualMove(text, range, false, go.count, edit.paneWrapWidth(p, pane)), .column => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.lineEndOffset(text, line)]; return pane.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); }, .view_top => return pane.gotoWindow(text, range, .top, go.count), .view_center => return pane.gotoWindow(text, range, .center, go.count), .view_bottom => return pane.gotoWindow(text, range, .bottom, go.count), }, .view => |view| switch (view) { .top => { pane.scrollBy(pane.cur_row - pane.scroll()); pane.ensureCursorVisible(); }, .center => { pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); pane.ensureCursorVisible(); }, .bottom => { pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, .scroll_down => return pane.scrollViewMove(text, range, 1), .scroll_up => return pane.scrollViewMove(text, range, -1), }, .find => |find| { const op: u8 = switch (find.kind) { .forward => @intCast(config.find_char_fwd), .backward => @intCast(config.find_char_back), .till_forward => @intCast(config.till_char_fwd), .till_backward => @intCast(config.till_char_back), }; pane.find_op = op; pane.find_ch = find.char; return pane.findMove( text, range, find.char, find.kind == .forward or find.kind == .till_forward, find.kind == .till_forward or find.kind == .till_backward, find.count, ); }, .replace_char => |char| return edit.normalReplaceChar(p, pane, char), .match_bracket => { const lines = edit.paneCursorLines(p, pane) catch return; const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return; return pane.pointMove(text, range, modal.offsetAt(text, mc)); }, .textobject => |object| return edit.textobjectSelect(p, pane, text, object.char, object.around), .surround_add => |char| return edit.surroundAdd(p, pane, char), .surround_delete => |char| return edit.surroundDelete(p, pane, char), .surround_replace => |replace| return edit.surroundReplace(p, pane, replace.from, replace.to), .paragraph => |paragraph| { const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select); return pane.setRange(text, 0, r2, false); }, .add_newline => |newline| return edit.addNewline(p, pane, newline.direction == .forward, newline.count), .diagnostic => |diagnostic| { const fwd = diagnostic.direction == .forward; if (!diagnostic.endpoint) { if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { pane.search_row = null; _ = p.searchStep(p.active, 1); return; } const rp = p.panes[pane.search_pane orelse return] orelse return; const rf = rp.file orelse return; pane.search_row = std.mem.count(u8, rf.content, "\n"); _ = p.searchStep(p.active, -1); }, .move => |move| switch (move.motion) { .left => { var target = cur; for (0..move.count) |_| target = modal.prevGrapheme(text, target); return pane.pointMove(text, range, target); }, .right => { var target = cur; for (0..move.count) |_| target = modal.nextGrapheme(text, target); return pane.pointMove(text, range, target); }, .down => return pane.verticalMove(text, range, true, move.count), .up => return pane.verticalMove(text, range, false, move.count), .next_word_start => return pane.wordMove(text, range, move.count, .next_word_start), .prev_word_start => return pane.wordMove(text, range, move.count, .prev_word_start), .next_word_end => return pane.wordMove(text, range, move.count, .next_word_end), .next_long_word_start => return pane.wordMove(text, range, move.count, .next_long_word_start), .prev_long_word_start => return pane.wordMove(text, range, move.count, .prev_long_word_start), .next_long_word_end => return pane.wordMove(text, range, move.count, .next_long_word_end), }, .repeat_find => |count| { if (pane.find_op == 0) return; const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd; const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back; var repeated = range; var moved = false; for (0..count) |_| { const cc = modal.selectionCursor(text, repeated); const target = modal.findTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; repeated = if (pane.select) modal.moveSelectionCursor(text, repeated, target, true) else modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true); moved = true; } if (!moved) return; return pane.setRange(text, 0, repeated, false); }, .line => |line_kind| switch (line_kind) { .start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .end => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pane.pointMove(text, range, ls + nw); }, }, .goto_line => |go| { if (!go.explicit) return; const line = @min(@as(usize, go.count) - 1, Pane.maxLine(text)); return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, .page => |page| switch (page.kind) { // Counts were parsed historically but these four text view // actions intentionally move exactly one viewport unit. .half_down => return pane.halfPageMove(text, range, true), .half_up => return pane.halfPageMove(text, range, false), .down => return pane.scrollViewMove(text, range, @as(i32, pane.rows)), .up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)), }, .insert => |insert| { const where: edit.InsertAt = switch (insert.kind) { .at => .at, .append => .append, .line_start => .line_start, .line_end => .line_end, .open_below => .open_below, .open_above => .open_above, }; return edit.enterInsert(p, pane, where, insert.count); }, .select => |select| switch (select.kind) { .mode => { if (pane.select) { pane.select = false; } else { pane.select = true; if (pane.msel.active) { pane.setRange(text, 0, range, true); } else if (!pane.vsel.active) { pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; } else { pane.vsel.explicit = true; } } pane.cur_pinned = true; }, .line => return pane.lineSelect(text, range, select.count), .line_bounds => return pane.lineBoundsSelect(text, range), .shrink_to_line_bounds => return pane.shrinkSelToLineBounds(text, range), .collapse => return pane.setRange(text, 0, .{ .anchor = cur, .head = cur }, false), .flip => { const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; return pane.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit); }, .all => return pane.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { if (multi.kind == .keep_primary) { pane.nsel = 0; return; } return pane.multiSelAction(text, multi.kind, multi.count); }, .select_regex => |split| return p.startSelRegex(pane, split), .edit => |op| switch (op.kind) { .delete => return edit.normalDelete(p, pane, true), .delete_noyank => return edit.normalDelete(p, pane, false), .change => return edit.normalChange(p, pane), .yank => return edit.normalYank(p, pane), .replace_with_yank => return edit.normalReplaceYank(p, pane), .paste_after => return edit.pasteText(p, pane, p.yank orelse return, false, op.count), .paste_before => return edit.pasteText(p, pane, p.yank orelse return, true, op.count), .switch_case => return edit.normalCase(p, pane, .toggle), .lowercase => return edit.normalCase(p, pane, .lower), .uppercase => return edit.normalCase(p, pane, .upper), .join_lines => return edit.normalJoin(p, pane), .indent => return edit.normalIndent(p, pane, op.count, true), .unindent => return edit.normalIndent(p, pane, op.count, false), .comment_toggle => return edit.normalToggleComment(p, pane), .undo => return edit.doUndo(p, pane), .redo => return edit.doRedo(p, pane), }, .lsp => |request| return p.lspRequest(p.active, switch (request) { .definition => .definition, .declaration => .declaration, .type_definition => .type_definition, .implementation => .implementation, .references => .references, .format => .format, }, ""), .adjust_number => |delta| return edit.normalAdjustNumber(p, pane, delta), .leader => { p.leader_on = true; p.leader_n = 0; }, .command_line => { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), .search => return p.startPrompt(pane, .{ .search = config.search_marker }), .search_step => |direction| return p.lookWalk( if (direction == .forward) @as(i32, 1) else -1, ), } } // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- const Search = enum { text, find, grep }; const SearchStart = enum { top, cursor }; 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(); } pub fn startPrompt(p: *Pardes, pane: *Pane, request: union(enum) { search: []const u8, pipe: modal.Normal.PipeBehavior, save, }) void { const marker = switch (request) { .search => |marker| marker, .save => config.save_marker, .pipe => |how| pipe: { const file = pane.file orelse return; if (!panes.Output.fileTraits(file.output).saves) return; break :pipe selection_pipe.pipeMarker(how); }, }; p.seedTail(pane); if (!pane.tag_init) return; const dir = if (request == .save) paneDir(pane) else ""; const slash = request == .save and (dir.len == 0 or dir[dir.len - 1] != '/'); var room = pane.tag_tail.len - pane.tag_tail_len; if (marker.len > room) return; room -= marker.len; if (dir.len > room) return; room -= dir.len; if (slash and room == 0) return; const prefix_len = (p.tagPrefix(pane) catch return).len; const prompt_at: u16 = @intCast(pane.tag_tail_len); _ = pane.appendTag(marker); if (request == .save) { _ = pane.appendTag(dir); if (slash) _ = pane.appendTag("/"); } pane.prompt = switch (request) { .search => .{ .search = prompt_at }, .pipe => |how| .{ .pipe = .{ .at = prompt_at, .how = how } }, .save => .{ .save = prompt_at }, }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.normal.clear(); pane.tag_col = @intCast(prefix_len + pane.tag_tail_len); } /// Enter on a save input: the path is everything past the marker. fn submitSave(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const at = switch (pane.prompt) { .save => |a| a, else => return, }; const armed = pane.tag_tail[at..pane.tag_tail_len]; pane.tag_tail_len = at; // drop the prompt back to the seeded tail if (!std.mem.startsWith(u8, armed, config.save_marker)) return; const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n"); if (path.len == 0) return; p.saveTo(id, path); } /// Write the file this pane already has: acme's Put, and the one Save that /// needs no argument because the path was never a question. pub fn saveFile(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const f = if (pane.file) |*file| file else return; if (f.output != null) return; // nothing behind it yet: saveTo, with a path p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); } pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void { const pane = p.panes[id] orelse return; if (path.len == 0 or path[path.len - 1] == '/' or std.mem.indexOfScalar(u8, path, '\n') != null) return p.reportError(id, "save", error.NoFilename); const full = std.fs.path.resolvePosix( p.scratch.allocator(), &.{ paneDir(pane), path }, ) catch return; if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory); if (full.len > SavePath.cap) return p.reportError(id, "save", error.PathTooLong); const f = if (pane.file) |*file| file else { // a terminal writes its scrollback; an image and a PDF have nothing // of their own that is unwritten, so they have nothing to write if (pane.isTerminal()) p.askWrite(id, pane.serial, full); return; }; if (f.output != null and panes.Output.fileTraits(f.output).saves) { const owned = p.gpa.dupe(u8, full) catch return; p.gpa.free(f.path); f.path = owned; f.output = null; // an ordinary file pane from here on f.watch_after_save = true; pane.clearCwd(); pane.tag_init = false; // re-derive the tag as a plain file pane.tag_tail_len = 0; p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); return; } // its own path, spelled out: the in-place write, so the pane comes clean if (f.output == null and std.mem.eql(u8, f.path, full)) return p.saveFile(id); p.askWrite(id, pane.serial, full); } const SavePath = Effect.Buf(effect_path_cap); fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void { p.emit(.{ .save_text = .{ .pane = @intCast(id), .serial = serial, .path = SavePath.from(path), } }); } fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]); const kind: Search = if (std.mem.startsWith(u8, armed, config.find_marker)) .find else if (std.mem.startsWith(u8, armed, config.grep_marker)) .grep else .text; p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err); if (kind == .text) p.lookFirstHit(id); } fn lookFirstHit(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const rid = pane.search_pane orelse return; const rp = p.panes[rid] orelse return; if ((rp.file orelse return).content.len == 0) return; p.active = rid; p.lookWalk(1); if (p.active != rid) return; // walked out of the list; leave it there const txt = edit.currentSelText(p, rp) orelse return; // spent, exactly as the look chord spends it (handleKey) rp.vsel.active = false; rp.msel.active = false; rp.select = false; p.runBuiltin(config.look_cmd, rid, "", txt); } pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. const dir = paneDir(pane); const out = try arena.alloc(u8, filesystem.search_max_output_bytes); var out_len: usize = 0; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { var roots: [MAX_PANES][]const u8 = undefined; var nroots: usize = 0; for (p.panes) |slot| { const pp = slot orelse continue; const d = paneDir(pp); var covered = false; var k: usize = 0; while (k < nroots) { if (pathUnder(d, roots[k])) { covered = true; break; } if (pathUnder(roots[k], d)) { // this one swallows a root already kept nroots -= 1; roots[k] = roots[nroots]; continue; } k += 1; } if (covered) continue; roots[nroots] = d; nroots += 1; } for (roots[0..nroots]) |r| out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]); } else if (kind == .find) { out_len = try filesystem.find(arena, dir, pat, out); } else if (pane.hasPdf()) { if (comptime pdf_enabled) { const found = try panes.Pdf.searchRows( &pane.pdf.?, p.pdf_gpa, arena, pat, start == .cursor, out, ); out_len = found.bytes; nrows = found.rows; anchor = found.anchor; } } else { const lines = try edit.paneCursorLines(p, pane); // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| f.output == null else false; var idbuf: [16]u8 = undefined; // relative to `dir`, like Find's rows — and `dir` IS this file's // own directory, so that is exactly its basename const loc: []const u8 = if (has_path) std.fs.path.basename(pane.file.?.path) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong; const cl: usize = @intCast(@max(0, pane.cur_row)); const cc: usize = @intCast(@max(0, pane.cur_col)); for (lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), }); if (row.len > out.len - out_len) break; if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows; @memcpy(out[out_len..][0..row.len], row); out_len += row.len; nrows += 1; } } const content = try p.gpa.dupe(u8, out[0..out_len]); // the buffer records WHICH search filled it, pattern and all: Find and // Grep are builtins (words you can execute), the bare `/` is a key const from: panes.Output.Origin = switch (kind) { .text => .search, .find => .{ .cmd = .Find }, .grep => .{ .cmd = .Grep }, }; try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); } fn searchStep(p: *Pardes, id: usize, delta: i32) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; const rp = p.panes[rid] orelse return false; const rf = if (rp.file) |*f| f else return false; // one question covers both hazards: a freed slot can be reused by an // unrelated pane, and a buffer of PROSE has nowhere to step to const tr = panes.Output.fileTraits(rf.output); if (!tr.steps) return false; // fresh results: n starts at the first row, N has nothing behind it const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") + @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n')); var step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; if (delta == 0) return true; const direction: i64 = if (delta > 0) 1 else -1; while (step >= 0 and step < nrows) : (step += direction) { if (!panes.Output.isResultRow(rf, @intCast(step))) continue; _ = p.jumpResult(id, @intCast(step)); p.active = id; break; } return true; } fn jumpResult(p: *Pardes, id: usize, row: usize) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; const rp = p.panes[rid] orelse return false; const rf = if (rp.file) |*f| f else return false; const tr = panes.Output.fileTraits(rf.output); const ln = modal.lineSlice(rf.content, row); if (ln.len == 0) return false; const r: i32 = @intCast(row); pane.search_row = row; rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.nsel = 0; rp.cur_row = r; rp.cur_pinned = true; const off = rp.scroll(); const last = off + @as(i32, rp.rows) - 1; if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last); var realbuf: [4096]u8 = undefined; const span = if (tr.commands) wholeRowSpan(ln) else look.lineSpan(p, ln, paneDir(rp), &realbuf) orelse blk: { const target = panes.Output.location(ln); break :blk if (target.at.line > 0) look.Span{ .start = 0, .end = target.end } else null; }; if (span) |selected| { rp.cur_col = @intCast(selected.start); rp.look_at = .{ .row = r, .col0 = @intCast(selected.start), .col1 = @intCast(selected.end - 1), }; } else { rp.cur_col = 0; rp.look_at = null; } if (tr.commands) { p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); } else if (span) |selected| { p.lookAt(rid, ln[selected.start..selected.end]); // This is the newly visited result, not a selection carried into // the destination. Look cleared the previous source selection. rp.msel = .{ .active = true, .r0 = r, .r1 = r }; } p.armLookWalk(rid); return true; } pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!p.multiOnce()) return; // one question per keystroke, from the primary if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; if (kind != .status) { const f = pane.file orelse return; if (f.output != null) return; } if (arg.len > 128) return; // the effect's arg is a Buf(128) 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))) return; if (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null) return; } const off = if (pane.file) |f| modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }) else 0; p.lsp_seq +%= 1; 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.cur_row, .col = pane.cur_col, }; p.emit(.{ .lsp = .{ .id = p.lsp_seq, .kind = kind, .pane = @intCast(id), .offset = @intCast(off), .arg = .from(arg), } }); } 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.cur_row)), .col = @intCast(@max(0, pane.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); panes.File.setContent(p, f, replacement); const cursor = modal.positionAt(f.content, mapped_cursor); pane.cur_row = @intCast(cursor.row); pane.cur_col = @intCast(cursor.col); pane.vsel.active = false; pane.msel.active = false; pane.select = false; pane.sticky_col = -1; pane.ensureCursorVisible(); return true; } pub fn lspResponse(p: *Pardes, id: u32, response: ?[]const u8) void { 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 rows = response orelse return; const pane = p.panes[w.pane] orelse return; if (pane.serial != w.serial) return; 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 return; } if (rows.len == 0) { if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and pane.cur_row == w.row and pane.cur_col == w.col) edit.insertTab(p, pane); 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"); return p.lookAt(w.pane, ln[0..panes.Output.location(ln).end]); } 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; }; 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, }); _ = p.jumpResult(w.pane, row); } } // ---- n/N: the walk over look-able text ---- const max_look_rows = 8192; const LookFrom = struct { row: i32, col: ?i32, strict: bool = false }; fn lookEdge(delta: i32) LookFrom { return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null }; } fn lookStand(pane: *Pane) LookFrom { if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col) return .{ .row = s.row, .col = s.col0, .strict = true }; return .{ .row = pane.cur_row, .col = pane.cur_col }; } fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize { var n: usize = 0; var i = p.n_look_src; while (i > 0) { i -= 1; const id = p.paneBySerial(p.look_src[i]) orelse continue; out[n] = id; n += 1; } const looked = n; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const f = pane.file orelse continue; if (f.output == null) continue; for (out[0..looked]) |k| { if (k == id) break; } else { // insertion by serial descending — at most MAX_PANES // comparisons, which is not a sort worth naming var at = n; while (at > looked and p.panes[out[at - 1]].?.serial < pane.serial) : (at -= 1) out[at] = out[at - 1]; out[at] = id; n += 1; } } if (n == 0) { if (p.panes[p.active] == null) return out[0..0]; out[0] = p.active; n = 1; } return out[0..n]; } fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool { if (!on_start_row) return true; const c = from.col orelse return true; if (delta > 0) return if (from.strict) col0 > c else col0 >= c; return if (from.strict) col0 < c else col0 <= c; } fn wholeRowSpan(ln: []const u8) ?look.Span { var lo: usize = 0; while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1; const hi = std.mem.trimEnd(u8, ln, " \t\r").len; return if (hi > lo) .{ .start = lo, .end = hi } else null; } fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot { const lines = edit.paneCursorLines(p, pane) catch return null; const nrows: i32 = @intCast(lines.len); if (nrows == 0) return null; const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word; const dir = paneDir(pane); var realbuf: [4096]u8 = undefined; const start = std.math.clamp(from.row, 0, nrows - 1); var r = start; while (r >= 0 and r < nrows) : (r += delta) { if (budget.* == 0) return null; budget.* -= 1; const ln = lines[@intCast(r)]; if (pane.file) |*f| if (!panes.Output.isWalkRow(f, @intCast(r), ln)) continue; const on_start = r == start; switch (grain) { .word => { var best: ?Pane.LookSpot = null; var i: usize = 0; while (i < ln.len) { while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; const t0 = i; while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; if (i == t0) break; const sp = look.wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue; const col0: i32 = @intCast(t0 + sp.start); if (!lookPast(col0, from, on_start, delta)) continue; best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) }; if (delta > 0) break; // first one forward; keep the last one back } if (best) |b| return b; }, // one span per row, so there is nothing to keep and nothing to // scan past: the row either offers it or it does not .line, .whole => { const sp = span: { if (grain == .whole) break :span wholeRowSpan(ln); if (pane.file) |f| if (@as(usize, @intCast(r)) < f.location_rows.len) { const end = f.location_rows[@intCast(r)].location_end; if (end > 0) break :span @as(?look.Span, .{ .start = 0, .end = end }); }; break :span look.lineSpan(p, ln, dir, &realbuf); } orelse continue; const col0: i32 = @intCast(sp.start); if (lookPast(col0, from, on_start, delta)) return .{ .row = r, .col0 = col0, .col1 = @intCast(sp.end - 1) }; }, } } return null; } fn lookWalk(p: *Pardes, delta: i32) void { var buf: [MAX_PANES]usize = undefined; const order = p.lookWalkPanes(&buf); if (order.len == 0) return; const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null; const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0; const at = if (owner) |wanted| std.mem.indexOfScalar(usize, order, wanted) orelse active_at else active_at; var from = lookStand(p.panes[order[at]] orelse return); var budget: usize = max_look_rows; var k: usize = 0; while (k <= order.len) : (k += 1) { // `+ 2 * len` only so the backward subtraction stays unsigned const idx = (if (delta > 0) at + k else at + 2 * order.len - k) % order.len; const pane = p.panes[order[idx]] orelse continue; if (p.lookSpanIn(pane, from, delta, &budget)) |spot| return p.landLookSpot(order[idx], pane, spot); if (budget == 0) return; // gave up mid-pane: stay put, stay reversible from = lookEdge(delta); } } fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void { edit.pinPaneCursor(p, pane); // fresh out of tty mode the cursor still tracks the shell pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true }; pane.msel.active = false; pane.nsel = 0; pane.cur_row = spot.row; pane.cur_col = spot.col0; pane.cur_pinned = true; pane.look_at = spot; p.look_walk_owner = pane.serial; p.active = id; pane.ensureCursorVisible(); } // ---- edit operations (d / c / y / p) ---- // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- pub const ChromeTarget = struct { col: u16, row: u16 }; pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget { if (row < p.topBarHeight() + p.columnBarHeight()) return .{ .col = col, .row = row }; for (p.panes, 0..) |slot, id| { if (slot == null) continue; const rect = p.rects[id]; const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y; if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER) return .{ .col = col, .row = tag }; } for (0..p.ncol -| 1) |column| { const handle = p.col_x[column] + p.col_w[column] -| 1; if (col == handle) return .{ .col = handle, .row = row }; } for (0..p.ncol) |column| { if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue; for (0..p.col_n[column] -| 1) |index| { const rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (row == handle) return .{ .col = col, .row = handle }; } } for (p.panes, 0..) |slot, id| { if (slot == null) continue; const rect = p.rects[id]; const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y; if (row == tag and col >= rect.x and col < rect.x + rect.w) return .{ .col = col, .row = tag }; } for (0..p.ncol -| 1) |column| { const handle = p.col_x[column] + p.col_w[column] -| 1; if (@max(col, handle) - @min(col, handle) == 1) return .{ .col = handle, .row = row }; } for (0..p.ncol) |column| { if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue; for (0..p.col_n[column] -| 1) |index| { const rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle }; } } return null; } pub fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize { for (p.panes, 0..) |slot, id| { if (slot == null) continue; const r = p.rects[id]; if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return id; } return null; } pub fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit { const pane = p.panes[id] orelse return null; if (pane.file == null or (!clamp and supplied.serial != pane.serial)) return null; if (!std.math.isFinite(supplied.pixel_x) or !std.math.isFinite(supplied.pixel_y) or supplied.metrics.body_w == 0 or supplied.metrics.body_h == 0 or supplied.metrics.tagline_w == 0 or supplied.metrics.tagline_h == 0) return null; var mapped_hit = supplied; if (clamp) { mapped_hit.serial = pane.serial; const layer = for (&p.surface.body_layers) |*layer| { if (layer.rows > 0 and layer.serial == pane.serial) break layer; } else return null; const bw: f32 = @floatFromInt(mapped_hit.metrics.body_w); const bh: f32 = @floatFromInt(mapped_hit.metrics.body_h); const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const y0 = @as(f32, @floatFromInt(layer.viewport.y)) * bh; const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * bw; const y1 = y0 + @as(f32, @floatFromInt(layer.viewport.h)) * bh; mapped_hit.pixel_x = std.math.clamp(mapped_hit.pixel_x, x0, @max(x0, x1 - 0.001)); mapped_hit.pixel_y = std.math.clamp(mapped_hit.pixel_y, y0, @max(y0, y1 - 0.001)); } return p.reprojectBodyHit(mapped_hit); } fn cancelLookHover(p: *Pardes) void { if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| { if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| { if (pane.serial == preview.serial) if (pane.pdf) |*pv| pv.invalidateRaster(preview.probe.page); }; preview.deinit(p.pdf_gpa); p.pdf_hover_preview = null; }; p.look_hover_wait = null; p.look_hover_preview = null; } fn lookHoverPane(p: *const Pardes) ?usize { if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| return preview.pane; if (p.look_hover_preview) |preview| return preview.pane; if (p.look_hover_wait) |waiting| return waiting.pane; return null; } fn sameTagCell(a: ?TagHit, b: ?TagHit) bool { const first = a orelse return b == null; const second = b orelse return false; return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col; } pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 { var point = supplied orelse return null; if (point.kind != kind or point.id != id) return null; if (clamp) for (p.surface.tagLayers()) |*layer| { if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial) continue; const bw: f32 = @floatFromInt(point.metrics.body_w); const bh: f32 = @floatFromInt(point.metrics.body_h); const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; break; }; return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; } fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool { const first = a orelse return b == null; const second = b orelse return false; return first.serial == second.serial and first.row == second.row and first.col == second.col; } fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) void { const delay = config.look_preview_delay_frames orelse return; _ = delay; const id = p.paneAt(col, row) orelse return p.cancelLookHover(); const pane = p.panes[id] orelse return p.cancelLookHover(); _ = edit.pointerTextSelection(p, id, col, row, body_hit, tag_hit) orelse return p.cancelLookHover(); if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; if (p.look_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and sameBodyCell(shown.body_hit, body_hit) and sameTagCell(shown.tag_hit, tag_hit)) return; if (p.look_hover_wait) |waiting| if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and sameBodyCell(waiting.body_hit, body_hit) and sameTagCell(waiting.tag_hit, tag_hit)) return; p.cancelLookHover(); p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit }; } fn refreshLookHoverFromRaw(p: *Pardes) void { if (!p.pointer_inside or !p.raw_hover_intent) return; const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse return p.cancelLookHover(); p.hover_col = mapped.col; p.hover_row = mapped.row; const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit| p.bodyHitForPane(p.paneAt(mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover() else null; p.noteLookHover(mapped.col, mapped.row, body_hit, p.pointer_tag_hit); } fn advanceLookHover(p: *Pardes) void { const delay = config.look_preview_delay_frames orelse return; const waiting = if (p.look_hover_wait) |*w| w else return; waiting.frames +|= 1; if (waiting.frames < delay) return; const pane = p.panes[waiting.pane] orelse return p.cancelLookHover(); if (pane.serial != waiting.serial) return p.cancelLookHover(); const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse return p.cancelLookHover(); if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); const ready = waiting.*; p.look_hover_wait = null; p.pdf_hover_preview = .{ .col = ready.col, .row = ready.row, .pane = ready.pane, .serial = ready.serial, .probe = probe, }; pane.pdf.?.invalidateRaster(probe.page); return; }; const operand = p.pointerOperand(pane, pointed.sel); if (operand.text == null or operand.text.?.len == 0) return p.cancelLookHover(); var preview = operand.preview; if (preview) |*sel| sel.state = .done; p.look_hover_preview = .{ .col = waiting.col, .row = waiting.row, .pane = waiting.pane, .serial = waiting.serial, .sel = preview, .file_word = operand.file_word, .body_hit = waiting.body_hit, .tag_hit = waiting.tag_hit, }; p.look_hover_wait = null; } pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.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; p.dragUpdate(mapped.col, mapped.row, p.pointer_body_hit); } } p.refreshLookHoverFromRaw(); } pub fn abandonPanelAnimations(p: *Pardes) void { p.presentation.cancel(); p.cancelLookHover(); } /// A native scene pass proved unavailable. Config reads these exact bits, /// and the idle-clock predicate stops immediately once they are clear. pub fn disableSceneEffects(p: *Pardes) void { p.settings.scene_effects = .{}; } fn handleMouse(p: *Pardes, m: Mouse) void { // A click goes somewhere else: the question it leaves unanswered is // taken back, and the pane stays. A wheel only looks around. if (m.kind == .press and switch (m.button) { .left, .middle, .right => true, else => false, }) for (p.panes) |slot| { const pane = slot orelse continue; if (pane.prompt == .del_side) pane.prompt = .none; }; if (m.button == .back or m.button == .forward) { if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1); return; } const raw_col = @min(m.col, p.screen_w -| 1); const raw_row = @min(m.row, p.screen_h -| 1); p.pointer_raw_col = raw_col; p.pointer_raw_row = raw_row; const previous_hit = p.pointer_body_hit; p.pointer_body_hit = m.body_hit; const previous_tag_hit = p.pointer_tag_hit; p.pointer_tag_hit = if (m.tag_hit) |point| p.reprojectTagHit(point) else null; if (m.tag_hit != null and p.pointer_tag_hit == null and m.kind == .press) return; if (m.tag_hit == null and (m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) { if (previous_tag_hit) |previous| { var continued = previous; continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w)); continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h)); p.pointer_tag_hit = continued; } } if ((m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) { if (previous_tag_hit) |previous| if (p.pointer_tag_hit) |*current| { current.kind = previous.kind; current.id = previous.id; current.serial = previous.serial; }; } // A drag can leave the compact text viewport. Preserve its metrics so // the endpoint still clamps to that body's last visible logical row. if (m.body_hit == null and (m.kind == .drag or m.kind == .release) and p.drag == .select) { if (previous_hit) |previous| { var continued = previous; continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w)); continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h)); p.pointer_body_hit = continued; } } p.pointer_inside = true; if (m.button == .none and m.kind == .motion) { p.raw_hover_intent = true; p.refreshLookHoverFromRaw(); return; } p.raw_hover_intent = false; const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse { p.cancelLookHover(); if (m.kind == .release) p.dragRelease(m.button); return; }; const mcol = mapped.col; const mrow = mapped.row; p.hover_col = mcol; p.hover_row = mrow; const hovered = p.paneAt(mcol, mrow); // any press that is NOT the execute+select chord abandons a pending // chord argument if (m.kind == .press and !(m.button == config.select_button and p.drag == .select and p.drag.select.button == config.exec_button)) { if (p.chord_arg) |a| { p.gpa.free(a); p.chord_arg = null; } } if (mrow >= p.topBarHeight() and mrow < p.topBarHeight() + p.columnBarHeight() and m.kind == .press) { for (0..p.ncol) |column| { if (mcol < p.col_x[column] or mcol >= p.col_x[column] + @min(config.GUTTER, p.col_w[column])) continue; if (m.button == config.select_button) { p.header_drag = false; p.drag = .{ .column_move = .{ .serial = p.col_serial[column], .cur_x = mcol, .grab = mcol - p.col_x[column] } }; } return; } } if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; const bar = p.headerText(); p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll))); if (m.kind == .release) p.header_drag = false; return; } if (mrow < p.topBarHeight() + p.columnBarHeight() and m.kind == .press and (m.button == config.select_button or m.button == config.exec_button or m.button == config.look_button)) { if (m.button == config.select_button) if (p.panes[p.active]) |pane| { if (pane.tag_edit) exitTagEdit(pane); }; var column: ?usize = null; var x: u16 = 0; if (mrow >= p.topBarHeight()) { for (0..p.ncol) |c| if (mcol >= p.col_x[c] and mcol < p.col_x[c] + p.col_w[c]) { column = c; x = p.col_x[c] + TAG_TEXT_INSET; if (layout.findPane(p, p.active)) |active_col| { if (active_col.col != c and p.col_n[c] > 0) p.active = p.col_panes[c][0]; } break; }; if (column == null or mcol < x) return; } if (p.header_column != column) { p.rememberHeader(); p.topbar_col = null; p.header_anchor = null; p.header_scroll = 0; } p.header_column = column; const bar = p.headerText(); const at = @min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)); if (m.button == config.select_button) { p.topbar_col = @intCast(at); p.header_anchor = @intCast(at); p.header_insert = true; p.header_drag = true; } else { const selected = if (p.topbar_col) |cur| blk: { const a = p.header_anchor orelse cur; break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at); } else wordAtCol(bar, at); const word = p.scratch.allocator().dupe(u8, selected) catch return; p.exitHeader(); if (word.len > 0) { if (m.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) else p.heldSelection(p.active); _ = p.execute(p.active, p.withArg(word, held)); } else p.runBuiltin(config.look_cmd, p.active, "", word); } } return; } if (m.kind == .press) { p.exitHeader(); } switch (m.button) { .none, .back, .forward => {}, // hover and navigation handled above .wheel_up, .wheel_down => { if (m.kind != .press) return; // re-arms the drift guard below; vertical always scrolls, so // the answer is never in doubt here _ = config.wheelTick(&p.wheel_guard, true); const id = hovered orelse return; const pane = p.panes[id].?; if (pane.hasPdf()) panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) else pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; if (pane.hasPdf()) { panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back } else if (pane.file != null and !p.settings.wrap) { // wrapped there is nothing off to the right to reach const line = panes.File.sourceLine(pane, pane.cur_row); const visual = panes.File.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); const next: usize = if (m.button == .wheel_right) visual +| @as(usize, @intCast(config.wheel_cols)) else visual -| @as(usize, @intCast(config.wheel_cols)); pane.hscroll = @intCast(panes.File.rawAtDisplay(line, next)); } }, config.select_button => switch (m.kind) { .press => { if (p.drag == .select and p.drag.select.button == config.exec_button) { if (p.heldSelection(p.drag.select.id)) |tx| { if (p.chord_arg) |old| p.gpa.free(old); p.chord_arg = p.gpa.dupe(u8, tx) catch null; } return; } // acme 3-1: a select tap during a LOOK drag cancels the // look; the rest of the gesture is inert if (p.drag == .select and p.drag.select.button == config.look_button) { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(config.look_button)].state = .none; if (comptime pdf_enabled) panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } if (hovered) |mid| { const mr = p.rects[mid]; const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y; if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) { p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } }; return; } } for (0..p.ncol -| 1) |c| { if (mcol == p.col_x[c] + p.col_w[c] -| 1) { const corner: @FieldType(@FieldType(Drag, "border_v"), "corner") = if (p.seamIdxAt(c, mrow)) |k| .{ .col = c, .idx = k } else if (p.seamIdxAt(c + 1, mrow)) |k| .{ .col = c + 1, .idx = k } else null; p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner = corner, .cur_y = mrow } }; return; } } for (0..p.ncol) |cc| { if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue; if (p.seamIdxAt(cc, mrow)) |k| { p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; return; } } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; if (id != p.active) if (p.panes[p.active]) |previous| { if (previous.tag_edit) exitTagEdit(previous); }; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = p.bodyTop(r); if (mcol < r.x + config.GUTTER) { // gutter scrollbar: a left click scrolls UP to that row p.active = id; pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y))); } else if (mrow >= tag_y and mrow < tag_y + BOX_H) { if (mcol < r.x + TAG_TEXT_INSET) return; p.active = id; p.enterTagEdit(pane, if (p.tagColumn(.pane, id, p.pointer_tag_hit, false)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, r.x + TAG_TEXT_INSET)); pane.tag_anchor = pane.tag_col; p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; if (pane.tag_edit) exitTagEdit(pane); const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return; pane.clearPointerSelection(sel_slot); pane.sel[sel_slot] = pointed.sel; p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl, .pdf = panes.Pdf.pointerStart( p, pane, config.select_button, mcol, mrow, false, ), } }; } }, .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), .release => { if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(config.select_button); }, .motion => {}, }, config.exec_button, config.look_button => switch (m.kind) { .press => { // acme 1-2 / 1-3: the execute (Cut) or look (Paste) button // tapped while the select button holds a selection drag if (p.drag == .select and p.drag.select.button == config.select_button) { p.chordCutPaste(m.button == config.exec_button); return; } // acme 2-3 / 3-2: tapping the OTHER of execute/look during // an execute or look drag cancels it (textselect23's mask) if (p.drag == .select) { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(p.drag.select.button)].state = .none; if (comptime pdf_enabled) panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; // the tag row and the body's first, exactly as the select // arm above derives them const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = p.bodyTop(r); const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H; if (mcol < r.x + config.GUTTER and !on_tag) { const local = @as(i32, mrow) - @as(i32, body_y); if (m.button == config.look_button) p.active = id; pane.scrollBy(if (m.button == config.look_button) local else -local); } else if (edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit)) |pointed| { if (m.button == config.look_button) p.active = id; pane.clearPointerSelection(@intFromEnum(m.button)); pane.sel[@intFromEnum(m.button)] = pointed.sel; p.drag = .{ .select = .{ .id = id, .button = m.button, // A native body gesture stays native even when its // point later misses a letterbox or selection. .pdf = panes.Pdf.pointerStart( p, pane, m.button, mcol, mrow, pointed.on_tag, ), }, }; } }, .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), .release => { if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(m.button); }, .motion => {}, }, } } /// Index of the pane pair in column `c` whose seam (see seamRowOf) is screen /// row `mrow`. Out-of-range columns simply have no seam. fn seamIdxAt(p: *const Pardes, c: usize, mrow: u16) ?usize { if (c >= p.ncol) return null; for (0..p.col_n[c] -| 1) |k| if (mrow == seamRowOf(p, c, k)) return k; return null; } fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) void { switch (p.drag) { .border_v => |*d| { const c = d.left_col; d.cur_x = if (c + 1 < p.ncol) clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol) else mcol; if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) { const a = p.rects[p.col_panes[k.col][k.idx]]; const b = p.rects[p.col_panes[k.col][k.idx + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else { d.cur_y = mrow; }; }, .border_h => |*d| { const cc = d.col; const k = d.top_idx; if (k + 1 < p.col_n[cc]) { const a = p.rects[p.col_panes[cc][k]]; const b = p.rects[p.col_panes[cc][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else d.cur_y = mrow; }, .column_move => |*d| d.cur_x = mcol, .move => |*d| { d.cur_x = mcol; d.cur_y = mrow; }, .select => |*s| { if (p.panes[s.id]) |pane| { const r = p.rects[s.id]; const b = @intFromEnum(s.button); if (pane.body_rows > 0 and pane.sel[b].r0 >= BOX_H) if (body_hit) |supplied| { const mapped_hit = p.bodyHitForPane(s.id, supplied, true) orelse return; pane.sel[b].c1 = @as(i32, mapped_hit.col) - config.GUTTER; pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H; return; }; if (pane.sel[b].r0 < BOX_H) if (p.tagColumn(.pane, s.id, p.pointer_tag_hit, true)) |value| { pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll; pane.sel[b].r1 = 0; return; }; pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); // Sel row BOX_H is the body's FIRST row, the same thing // pointerTextSelection means by it -- so the drag has to // measure from the body's top, notice bands included, or a // selection made while a message is up lands rows away // from the pointer. const body_y = p.bodyTop(r); const body_h = r.h -| BOX_H; pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) 0 else @as(i32, @min(@max(mrow, body_y), body_y + body_h - 1)) - @as(i32, body_y) + @as(i32, BOX_H); if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP; if (comptime pdf_enabled) panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } }, .tag => |d| { const pane = p.panes[d.id] orelse return; const c = if (p.tagColumn(.pane, d.id, p.pointer_tag_hit, true)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, p.rects[d.id].x + TAG_TEXT_INSET); const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll))); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, } } fn reportGesture( p: *Pardes, id: usize, cmd: Builtin, text: []const u8, on_tag: bool, operand: PointerOperand, chorded: bool, ) bool { const scripted = p.panes[id] orelse return false; if (scripted.fs.readers == 0) return false; const is_look = cmd == config.look_cmd; const action: ctlfs.events.Action = if (is_look) (if (on_tag) .tag_look else .body_look) else (if (on_tag) .tag_exec else .body_exec); const named = std.meta.stringToEnum(Builtin, commandText(text)) != null; const range = p.gestureRange(id, text, on_tag, operand); var flag: u32 = if (named) ctlfs.events.flag_builtin else 0; if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion; if (is_look) { if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename; } else if (chorded) flag |= ctlfs.events.flag_chorded; return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text); } fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) ctlfs.pane.State.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = @min(sel.c0, sel.c1); const at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead))); const q0: u32 = @intCast(@min(at, tag.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } const f = if (pane.file) |*file| file else return .{}; if (operand.preview) |selection| if (pane.pointerSelection(selection)) |rows| { if (rows.len == 0) return .{}; const first = rows[0]; const last = rows[rows.len - 1]; const q0 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, first.row))) + @min(first.lo, panes.File.sourceLine(pane, first.row).len); const q1 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, last.row))) + @min(last.hi, panes.File.sourceLine(pane, last.row).len); // A rectangle may contain disjoint pieces. Such an operand uses // the event's expansion text instead of inventing a source range. if (q1 < q0 or !std.mem.eql(u8, f.content[q0..q1], text)) return .{}; return .{ .q0 = @intCast(q0), .q1 = @intCast(q1) }; }; const start: ?modal.Cursor = if (operand.file_word) |w| .{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) } else if (operand.expanded orelse operand.preview) |sel| lead: { // A selection's leading corner in reading order, converted the way // `pointerOperand` converts the click itself. const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H); if (top < 0) break :lead null; const w = pane.wrapAt(top); const col = edit.paneByteAtDisplay(p, pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane)); break :lead .{ .row = @intCast(@max(0, w.line)), .col = @intCast(@max(0, col)), }; } else null; const cursor = start orelse return .{}; const q0: u32 = @intCast(@min(modal.offsetAt(f.content, cursor), f.content.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) }; } fn dispatchPointerBuiltin( p: *Pardes, id: usize, cmd: Builtin, text: ?[]const u8, gesture: ?struct { on_tag: bool, operand: PointerOperand }, ) void { const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); const operand = text orelse { if (gesture) |g| _ = p.reportGesture(id, cmd, "", g.on_tag, g.operand, arg != null); return; }; if (gesture) |g| if (p.reportGesture(id, cmd, operand, g.on_tag, g.operand, arg != null)) return; p.runBuiltin(cmd, id, "", p.withArg(operand, arg)); } fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { const gesture = pane.sel[sel_slot]; const rows = pane.pointerSelection(gesture) orelse return; const text = pane.rawPointerText(gesture) orelse return; if (rows.len == 0) return; const endpoint = rows[if (gesture.r1 >= gesture.r0) rows.len - 1 else 0]; const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return; const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane)))); const byte = if (gesture.c1 >= gesture.c0) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo; pane.cur_row = pane.surfRow(endpoint.row); var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t"); if (pane.ovl) |overlay| { const relative = pane.cur_row - overlay.row; if (relative >= 0 and relative < modal.lineCount(overlay.text)) shown = modal.lineSlice(overlay.text, @intCast(relative)); } pane.cur_col = @intCast(modal.graphemeStart(shown, @min(shown.len, byte -| endpoint.prompt_bytes))); pane.cur_pinned = true; pane.msel.active = false; pane.vsel.active = false; pane.nsel = 0; pane.select = false; pane.append_at = null; pane.sticky_col = -1; pane.normal.clear(); edit.setYank(p, text); } fn dragRelease(p: *Pardes, button: Mouse.Button) void { // A different button's release belongs to a chord or another host // stream; only the button which began this gesture may end it. if (p.drag.button() != button) return; defer p.drag = .none; switch (p.drag) { .border_v => |d| { const c = d.left_col; const old_seam = p.col_x[c] + p.col_w[c] -| 1; // the handle is the left pane's LAST column (inclusive), so // the new width includes it — a no-drag click changes nothing if (c + 1 < p.ncol and d.cur_x != old_seam) layout.setColumnPairWidth(p, c, (d.cur_x + 1) -| p.col_x[c]); if (d.corner) |k| { if (d.cur_y != seamRowOf(p, k.col, k.idx)) layout.applyRowSplit(p, k.col, k.idx, d.cur_y); } }, .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx)) layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) { .stay => {}, .reorder => |target| layout.reorderColumn(p, column, target), .edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]), }, .move => |d| { layout.movePane(p, d.id, d.cur_x, d.cur_y); if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial; // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { if (layout.findPane(p, d.id)) |f| if (f.col == 0) p.evictLonePristineTty(0, d.id); }; }, .select => |s| { const pane = p.panes[s.id] orelse return; if (comptime pdf_enabled) { if (s.pdf.native) { var release = panes.Pdf.pointerRelease( p, pane, s.pdf, s.button, s.chorded, ); defer release.deinit(p.pdf_gpa); if (release.probe) |*probe| if (probe.link != null) { p.followPdfLink(s.id, probe); return; }; // A native PDF page has no byte offsets of its own to // report, so a scripted pane cannot intercept this one. if (release.action) |action| p.dispatchPointerBuiltin( s.id, if (action == .look) config.look_cmd else config.exec_cmd, release.text, null, ); return; } } if (s.chorded) { // a cut/paste chord consumed this drag; the release is inert pane.sel[@intFromEnum(s.button)].state = .none; return; } const b = @intFromEnum(s.button); pane.sel[b].state = .done; if (s.button == config.select_button) { const sl = pane.sel[sel_slot]; const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; if (!dragged) pane.sel[sel_slot].state = .none; if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {}; if (dragged and pane.mode == .tty) p.mirrorTtySelection(pane); const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { const w = pane.wrapAt(body_vis); pane.cur_row = w.line; pane.cur_col = edit.paneByteAtDisplay( p, pane, w.line, w.at, sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ); pane.cur_pinned = true; if (edit.clampFileCursor(pane)) pane.ensureCursorVisible(); pane.msel.active = false; pane.normal.clear(); pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is pane.select = false; pane.append_at = null; pane.sticky_col = -1; if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { const clk = pane.sel[b]; // A right-click on a line NUMBER looks at that line of // this file -- a sticky context header's number included, // since the row map holds each header's own source line. if (s.button == config.look_button and clk.r0 >= BOX_H and clk.r0 == clk.r1 and clk.c0 == clk.c1 and clk.c0 >= 0 and pane.mode != .tty) if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) { const line = pane.wrapAt(clk.r0 - BOX_H).line; if (line >= 0) { pane.sel[b].state = .none; var buf: [48]u8 = undefined; const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return; p.runBuiltin(config.look_cmd, s.id, "", target); return; } }; var operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) pane.sel[b].state = .none; if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) { pane.cur_row = operand.row; pane.cur_col = operand.col; if (edit.clampFileCursor(pane)) pane.ensureCursorVisible(); } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; if (clk.r0 < BOX_H and s.button == config.exec_button and pane.tag_name_len != null) { const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width; if (!p.commitTagName(pane)) return; // Executing a staged filename commits it; executing its // command tail uses the new identity (especially Save). if (on_name) return; const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width)); if (operand.expanded) |*sel| { sel.c0 += shift; sel.c1 += shift; } if (operand.preview) |*sel| { sel.c0 += shift; sel.c1 += shift; } pane.sel[b].c0 += shift; pane.sel[b].c1 += shift; } p.dispatchPointerBuiltin(s.id, cmd, txt, .{ .on_tag = clk.r0 < BOX_H, .operand = operand, }); } }, .tag => {}, // dragUpdate already left the tag cursor + selection set .none => {}, } } fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; if (comptime pdf_enabled) if (pane.pdf) |pv| { if (!s.chorded) { s.chorded = true; pane.sel[sel_slot].state = .none; } // A PDF is immutable: its Cut-side chord is Snarf (copy), while // Paste has no document mutation to perform. if (cut and pv.selection_text.len > 0) edit.setYank(p, pv.selection_text); return; }; if (pane.mode == .tty) { if (!s.chorded) { s.chorded = true; pane.sel[sel_slot].state = .none; // drop the sweep highlight } if (cut) return; const y = p.yank orelse return; if (y.len == 0) return; if (panes.Terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999)); var mb: [32]u8 = undefined; if (panes.Terminal.mouseFormatSgr(pane)) { p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return); } else { // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them const cb: u8 = @intCast(32 + @min(col, 222)); const rb: u8 = @intCast(32 + @min(row, 222)); p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb }); } } edit.typeToTty(p, s.id, pane, y); return; } if (!s.chorded) { s.chorded = true; const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; // both ends of the sweep through the same last-frame map, so a // chord over wrapped rows cuts the text that was under it const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H))); const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H))); const row0 = w0.line; const row1 = w1.line; const col0 = edit.paneByteAtDisplay(p, pane, w0.line, w0.at, sl.c0 - pfx); const col1 = edit.paneByteAtDisplay(p, pane, w1.line, w1.at, sl.c1 - pfx); pane.cur_row = row1; pane.cur_col = col1; pane.cur_pinned = true; pane.msel.active = false; pane.normal.clear(); if (!pane.isTerminal()) pane.mode = .normal; pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; pane.nsel = 0; } if (cut) { if (pane.vsel.active) edit.normalDelete(p, pane, true); } else if (pane.vsel.active) { edit.normalReplaceYank(p, pane); } else if (pane.file) |*f| { // bare cursor: splice the register in literally (no linewise // magic) so a paste right after a cut restores the text exactly const y = p.yank orelse return; if (y.len == 0) return; const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; edit.pushUndo(p, pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; panes.File.setContent(p, f, new); pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { pane.cur_row = @intCast(end.row); pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(f.content, end.row), end.col)); } else { // the register ended in '\n': the cursor lands ON that newline pane.cur_row = @intCast(end.row -| 1); pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); } pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } else edit.pasteText(p, pane, p.yank orelse return, true, 1); } /// Tty: a shell in the caller's directory, raw from the first frame, /// stacked below the caller like Alt-n's new shell. pub fn spawnTty(p: *Pardes, from: usize) void { p.spawnTtyWithMount(from, false); } pub fn spawnV9fsTty(p: *Pardes, from: usize) void { if (comptime !hosted or @import("builtin").os.tag != .linux) return p.reportError(from, "Tty9p", error.LinuxRequired); if (p.fs.socket_path.len == 0) return p.reportError(from, "Tty9p", error.SessionSocketRequired); p.spawnTtyWithMount(from, true); } fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void { const src = p.panes[from] orelse return; const free = p.freeSlot() orelse return; const nt = p.newShell(free, paneDir(src)) catch return; nt.greet = !v9fs; nt.v9fs_on_spawn = v9fs; nt.mode = .tty; const parent = layout.splitParent(p, from); const f = layout.findPane(p, parent).?; layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, parent, nt); p.active = free; p.stillborn_joiner = nt.serial; } /// when a doc lands in `col`, a lone pristine shell there is clutter — drop /// it; absorbVWeight hands its space to the doc fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { var n_tty: usize = 0; var tty_id: usize = 0; for (0..p.col_n[col]) |k| { const cid = p.col_panes[col][k]; if (p.panes[cid]) |ct| if (ct.isTerminal()) { n_tty += 1; tty_id = cid; }; } if (n_tty != 1) return; const tt = p.panes[tty_id] orelse return; // No typing, cursor on the first prompt line, no scrollback: this is // the throwaway boot placeholder a document may replace. if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or panes.Terminal.scrollbar(tt).total > tt.rows) return; p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows layout.absorbVWeight(p, tty_id, null); layout.removePane(p, tty_id); p.panes[tty_id] = null; if (p.active == tty_id) p.active = keep_id; } /// A pane that just joined a column whose only other pane is stillborn — /// an empty scratch nobody typed into, body or tag (what Newcol makes and /// what a closed column's last pane leaves behind) — takes the column /// whole: the placeholder was only holding the space for it. Run at the /// end of a step: the pane that asked for the joiner is often the /// placeholder itself, and its caller is still holding it. fn replaceStillborn(p: *Pardes) void { const serial = p.stillborn_joiner orelse return; p.stillborn_joiner = null; const new_id = p.paneBySerial(serial) orelse return; const f = layout.findPane(p, new_id) orelse return; if (p.col_n[f.col] != 2) return; const old_id = p.col_panes[f.col][1 - f.idx]; const old = p.panes[old_id] orelse return; const file = old.file orelse return; const out = file.output orelse return; if (out.from != .cmd or out.from.cmd != .New) return; if (file.revision != 0 or file.content.len != 0) return; if (old.tag_init and !std.mem.eql(u8, old.tagSlice(), defaultTail(old))) return; const was_active = p.active == old_id; layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); if (was_active) p.active = new_id; } fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; if (p.takesCommandLine(i)) return i; }; const free = p.freeSlot() orelse return null; const nt = p.newShell(free, dir) catch return null; nt.greet = false; const rc = if (p.ncol > 0) p.ncol - 1 else 0; if (p.col_n[rc] > 0) { const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); const f = layout.findPane(p, src).?; layout.splitBelow(p, src, nt); layout.insert(p, f.col, f.idx + 1, free); } else layout.insert(p, rc, p.col_n[rc], free); return free; } // ---- doc panes ---- pub fn openTutorView(p: *Pardes, id: usize) !*Pane { const content = try p.gpa.dupe(u8, tutor_text); errdefer p.gpa.free(content); const path = try p.gpa.dupe(u8, "/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.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.cur_pinned = true; p.active = 0; p.sync(); return pane; } fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { if (comptime !pdf_enabled) return; if (pane.pdf == null) return; if (hit(key, config.leave_pane)) { pane.normal.clear(); pane.select = false; return p.runBuiltin(.Last, p.active, "", null); } if (pane.normal.prefix == .none and isPrefix(key, 'f')) { pane.normal.clear(); return p.runBuiltin(.PdfSections, p.active, "", null); } const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); switch (parsed) { .pending, .ignored, .unbound => {}, .action => |semantic| { const result = panes.Pdf.applyNormal( &pane.pdf.?, p.pdf_gpa, semantic, p.native_images, .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h }, panes.Pdf.paneViewport(p, pane), panes.Pdf.paneGeometry(p, pane), ); if (result.page_changed) panes.Pdf.resetPageChrome(pane); switch (result.host) { .none => {}, .leader => { p.leader_on = true; p.leader_n = 0; }, .command_line => { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => p.lookWalk(1), .search_backward => p.lookWalk(-1), } }, } } // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; pane.normal.clear(); if (pane.hasPdf()) { if (landing == .keep) return; if (comptime pdf_enabled) { const pv = &pane.pdf.?; if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane); } return; } if (at.line == 0) return; if (pane.file) |*f| { if (at.line > panes.File.nlines(p.gpa, f)) return; if (landing == .center) { const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } } pane.cur_row = @intCast(at.line - 1); pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0; pane.cur_pinned = true; if (at.end_line != 0) return p.selectSpan(pane, at); pane.ensureCursorVisible(); } fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void { const text = edit.flatSurface(p, pane) catch return; // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a // 1-based zero is the same nothing an absent number is const acol: i32 = @intCast(at.col -| 1); const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32); const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); pane.setRange(text, 0, r, true); } fn openPaneTarget(p: *const Pardes, path: []const u8, at: look.Spot) ?look.Target { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { return .{ .pane = .{ .id = i, .at = .{} } }; }; if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { return .{ .pane = .{ .id = i, .at = at } }; }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; return .{ .pane = .{ .id = i, .at = at } }; } return null; } fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { const target = p.openPaneTarget(path, at) orelse return false; p.clearNavigationSelection(p.panes[target.pane.id].?); p.focusPaneLine(target.pane.id, target.pane.at, .center); return true; } /// Navigation starts a new selection at the destination. pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void { pane.vsel = .{}; pane.msel = .{}; pane.nsel = 0; pane.select = false; pane.normal.clear(); pane.append_at = null; pane.sticky_col = -1; for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot); pane.sel = @splat(.{}); if (comptime pdf_enabled) if (pane.pdf) |*state| { state.clearDrag(); state.clearSelection(p.pdf_gpa); }; if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none; } /// Resolve without changing focus or falling back to search. PDF links use /// the same location rules as an ordinary Look before offering a choice. fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8) look.Target { const trimmed = std.mem.trim(u8, txt, " \t\r\n"); const pl = look.parsePathLine(trimmed); if (pl.path.len > 0) if (p.openPaneTarget(pl.path, pl.at)) |target| return target; var found: look.Target = .none; var seen: [MAX_PANES][]const u8 = undefined; var nseen: usize = 0; var n: usize = 0; cand: while (n <= p.njumps) : (n += 1) { const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane; if (n > 0 and k == id) continue; // tried first, skip the rerun const op = p.panes[k] orelse continue; const dir = paneDir(op); for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; seen[nseen] = dir; nseen += 1; if (pl.path.len > 0 and pl.path[0] != '/') { var joinbuf: [2048]u8 = undefined; if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| if (p.openPaneTarget(j, pl.at)) |target| return target; } found = look.resolve(p, txt, dir, realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } return found; } fn locationText(p: *Pardes, path: []const u8, at: look.Spot) ?[]const u8 { if (std.mem.indexOfAny(u8, path, "\r\n") != null) return null; const arena = p.scratch.allocator(); if (at.line == 0) return arena.dupe(u8, path) catch null; if (at.end_line != 0) { if (at.col == 0 and at.end_col == 0) return std.fmt.allocPrint(arena, "{s}:{d}-{d}", .{ path, at.line, at.end_line }) catch null; return std.fmt.allocPrint(arena, "{s}:{d}:{d}-{d}:{d}", .{ path, at.line, at.col, at.end_line, at.end_col }) catch null; } if (at.col != 0) return std.fmt.allocPrint(arena, "{s}:{d}:{d}", .{ path, at.line, at.col }) catch null; return std.fmt.allocPrint(arena, "{s}:{d}", .{ path, at.line }) catch null; } fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 { const trimmed = std.mem.trim(u8, text, " \t\r\n"); if (trimmed.len == 0) return null; var realbuf: [4096]u8 = undefined; switch (p.resolveLookTarget(id, trimmed, &realbuf)) { .none => return null, .url => |url| { if (url.len > 256) return null; for (url) |c| if (c <= 0x20 or c == 0x7f) return null; return p.scratch.allocator().dupe(u8, url) catch null; }, .file => |file| return p.locationText(file.path, file.at), .image => |iv| return p.locationText(iv.path, .{}), .dir => |dir| return p.locationText(dir, .{}), .pane => |target| { if (target.id >= p.panes.len) return null; const pane = p.panes[target.id] orelse return null; if (pane.file) |file| return p.locationText(file.path, target.at); if (comptime pdf_enabled) if (pane.pdf) |doc| return p.locationText(doc.path, target.at); if (pane.image) |iv| return p.locationText(iv.path, target.at); var path: [32]u8 = undefined; return p.locationText(std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at); }, } } pub fn pdfLinkLocation(p: *Pardes, pane: *const Pane, link: if (pdf_enabled) pdf.Link else void) ?[]const u8 { if (comptime !pdf_enabled) return null; const state = pane.pdf orelse return null; const id = p.paneIdOf(pane) orelse return null; return switch (link.destination) { .none => null, .internal => |dest| if (dest.page < state.document.pages) p.locationText(state.path, .{ .line = dest.page + 1 }) else null, .external => |uri| p.canonicalLookLocation(id, uri), }; } fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void { if (comptime !pdf_enabled) return; const pane = p.panes[id] orelse return; const link = probe.link orelse return; const target = p.pdfLinkLocation(pane, link) orelse return; p.clearNavigationSelection(pane); if (p.canonicalLookLocation(id, probe.text)) |visible| { if (!std.mem.eql(u8, visible, target)) { const content = std.fmt.allocPrint(p.gpa, "{s}\n{s}\n", .{ visible, target }) catch |err| return p.reportError(id, "PDF link choices", err); panes.Output.fillResults(p, id, std.fs.path.dirname(pane.pdf.?.path) orelse ".", .pdf_links, "", content, null) catch |err| return p.reportError(id, "PDF link choices", err); return; } } switch (link.destination) { .internal => |dest| { p.noteHaptic(.look); p.noteLookSource(id); p.active = id; panes.Pdf.revealOutlineDestination(p, pane, dest); }, .external => p.lookAt(id, target), .none => {}, } } pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const pane = p.panes[id] orelse return; p.noteHaptic(.look); p.noteLookSource(id); const command = std.mem.trim(u8, operand, " \t\r\n"); if (config.commandWord(command)) |cmd| { if (p.execute(id, cmd)) |dst| p.active = dst; return; } // The operand may borrow selected terminal/PDF text released below. const txt = p.scratch.allocator().dupe(u8, operand) catch return; const trimmed = std.mem.trim(u8, txt, " \t\r\n"); p.clearNavigationSelection(pane); const pl = look.parsePathLine(trimmed); if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; var realbuf: [4096]u8 = undefined; const found = p.resolveLookTarget(id, txt, &realbuf); switch (found) { .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (pane.image != null or bmode == .tty) return; p.runSearch(id, trimmed, .text, .cursor) catch |err| { p.reportError(id, "search", err); return; }; const at = pane.search_row; _ = p.searchStep(id, 1); if (at != null and pane.search_row == at) { pane.search_row = null; _ = p.searchStep(id, 1); } }, .pane => |t| { if (t.id >= MAX_PANES) return; const target = p.panes[t.id] orelse return; p.clearNavigationSelection(target); p.focusPaneLine(t.id, t.at, .center); }, .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { for (p.panes, 0..) |slot, i| { if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { p.clearNavigationSelection(tt); p.active = i; p.emitWrite(i, "ls\r"); return; }; } const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); nt.greet = true; const src = layout.splitParent(p, id); const f = layout.findPane(p, src).?; layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; p.stillborn_joiner = nt.serial; }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); return; }; // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); p.placeDoc(id, free, nt); layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; if (target.at.end_line != 0) { const centered = nt.file.?.scroll; p.selectSpan(nt, target.at); nt.file.?.scroll = centered; } }, .image => |target| { if (p.focusPaneByPath(target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); }, } } const max_exec_depth = 8; pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { const pane = p.panes[id] orelse return null; const cmd = commandText(txt); if (cmd.len == 0) return null; if (p.exec_depth == 0) p.noteHaptic(.exec); if (p.executeBuiltinLine(id, cmd)) return null; if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; defer p.exec_depth -= 1; const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null; panes.Terminal.padOutputBelowEdits(p, dst); if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { p.reportError(id, "queue command", err); return dst; }) return dst; p.emitWrite(dst, cmd); p.emitWrite(dst, "\r"); return dst; } /// Trim and unwrap clickable command-word notation once for every command /// consumer, so startup lines and interactive Exec speak the same syntax. fn commandText(txt: []const u8) []const u8 { var cmd = std.mem.trim(u8, txt, " \t\r\n"); while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n"); return cmd; } pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { const cmd = commandText(txt); if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; const sp = std.mem.indexOfAny(u8, cmd, " \t"); const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; break :blk if (builtins.registry.takesArg(head)) head else null; }; const b = bi orelse return false; p.exec_depth += 1; defer p.exec_depth -= 1; const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else ""; p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else null); return true; } fn applyStartupConfig(p: *Pardes) void { p.announce = false; defer p.announce = true; var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; // A builtin says its own name before it runs, so a tagline click is // legible even when what it did is invisible. Whatever the builtin // then says for itself replaces the announcement. if (p.announce and p.settings.verbose and builtins.registry.announces(b)) { p.setMessage(id, @tagName(b)); pane.msg_announcement = true; } const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; builtins.registry.dispatch(b, c); } pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { const previous_colors = p.settings.colors; const previous_context_style = p.settings.tree_context_tag_style; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; const previous_column_tags = p.settings.column_tags; switch (setting.action) { .theme => { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) { colors.setThemeIndex(p, i); return; }; }, .font => { if (comptime !font_picker) return; const spec = config.Runtime.FontSpec.parse(arg orelse return) orelse return; const matches = fonts.list(p.scratch.allocator(), spec.name); if (matches.len == 0) return; if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return; p.font_request_taken = false; }, else => _ = p.settings.apply(setting, arg), } if (p.settings.colors != previous_colors) { for (p.panes) |slot| { const pane = slot orelse continue; if (pane.file) |*file| file.syntax_dirty = true; } } if (p.settings.tree_context_tag_style != previous_context_style) { for (p.panes, 0..) |slot, id| { p.surface.body_layers[id].rows = 0; const pane = slot orelse continue; pane.body_rows = 0; pane.body_visible_rows = 0; pane.context_row_limit = null; pane.wrap_n = 0; } p.abandonPanelAnimations(); } const transition_changed = setting.action == .transition and p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; if (p.settings.column_tags != previous_column_tags) { if (p.header_column != null) p.exitHeader(); } if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { p.abandonPanelAnimations(); } } pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { p.stillborn_joiner = nt.serial; const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { src_id = from_id; }; if (doc and src_id == null) { var n = p.njumps; while (n > 0) : (n -= 1) { const j = p.jumps[n - 1]; const pp = p.panes[j.pane] orelse continue; if (pp.serial != j.serial) continue; if (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf()) { src_id = j.pane; break; } } } if (doc and src_id == null) for (p.panes, 0..) |sl, i| { if (sl) |pp| if (i != free and (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf())) { src_id = i; break; }; }; if (!doc) if (layout.findPane(p, from_id)) |sf| { layout.insert(p, sf.col, sf.idx + 1, free); layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; if (src_id == null and doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true)) { p.active = free; return; } // Without room for a first-document column, split the Look origin // just like Tty. Never displace (or evict) a top-left bystander. const src = layout.splitParent(p, src_id orelse from_id); const sf = layout.findPane(p, src).?; layout.insert(p, sf.col, sf.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; } // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- pub fn dumpState(p: *Pardes) !void { const arena = p.scratch.allocator(); var slot_to_pane: [MAX_PANES]?usize = @splat(null); var dump_panes: [MAX_PANES]dump.Pane = undefined; var panes_len: usize = 0; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; slot_to_pane[id] = panes_len; const tag = try p.tagText(arena, pane); const body = try body_layer.bodyText(p, arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); var dp: dump.Pane = if (pane.file) |*f| try panes.File.dumpPane( arena, pane, f, tag, body, scroll, if (f.output) |o| panes.Output.word(o.from) else "", if (f.output) |*o| o.arg() else "", ) else if (pane.hasPdf()) blk: { if (comptime !pdf_enabled) unreachable; const pv = &pane.pdf.?; break :blk try panes.Image.dumpPane(p, arena, pane, tag, body, pv.page, pv.path, &.{}); } else if (pane.image) |iv| try panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else try panes.Terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null; dump_panes[panes_len] = dp; panes_len += 1; } var columns: [MAX_COLS]dump.Column = undefined; var columns_len: usize = 0; var column_ids: [MAX_COLS][MAX_PANES]usize = undefined; for (0..p.ncol) |c| { var ids_len: usize = 0; for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_panes[c][k]]) |compact| { column_ids[c][ids_len] = compact; ids_len += 1; }; if (ids_len > 0) { columns[columns_len] = .{ .tag = if (p.col_tags[c].custom) p.columnTag(c) else null, .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), .panes = column_ids[c][0..ids_len], }; columns_len += 1; } } var mounts: [filesystem.max_mounts]dump.Mount = undefined; for (p.fs.mounts.items, 0..) |mount, i| mounts[i] = .{ .name = mount.name, .dial = mount.dial }; const state: dump.State = .{ .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, .topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null, .theme = p.theme().name, .locations_config = p.locations_config, .tree_context_tag_style = p.settings.tree_context_tag_style, .columns = columns[0..columns_len], .panes = dump_panes[0..panes_len], .mounts = mounts[0..p.fs.mounts.items.len], }; try dump.validate(state); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); if (p.dump_out) |d| p.gpa.free(d); p.dump_out = try out.toOwnedSlice(); p.emit(.write_dump); } pub fn restore(p: *Pardes, zon_bytes: []const u8) !*Pardes { var opts = p.opts; opts.cols = p.screen_w; opts.rows = p.screen_h; return initDump(p.gpa, opts, zon_bytes, p); } pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { return initDump(gpa, opts, zon_bytes, null); } fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .image_gpa = image_gpa, .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, .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(); if (previous) |old| { p.next_serial = old.next_serial; p.lsp_seq = old.lsp_seq; p.pipe_seq = old.pipe_seq; p.cell_pixels = old.cell_pixels; p.row_metrics = old.row_metrics; p.native_images = old.native_images; } for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; var parsed = try dump.readZon(gpa, zon_bytes, "load"); defer parsed.deinit(); const st = parsed.value; p.locations_config = st.locations_config; p.settings.tree_context_tag_style = st.tree_context_tag_style; for (st.mounts) |mount| { var already_mounted = false; for (p.fs.mounts.items) |existing| { if (!std.mem.eql(u8, mount.name, existing.name)) continue; if (!std.mem.eql(u8, mount.dial, existing.dial)) return error.MountConflict; already_mounted = true; break; } if (!already_mounted) try p.fs.mount(gpa, mount.name, mount.dial); } for (themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i); } for (st.panes, 0..) |src, i| { if (i >= MAX_PANES) break; const pane: *Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; // A host that runs shells gets them back: the old history // above a new shell in the directory it was in. The web // shell and an isolated build only ever replay it. const live = comptime (terminal_panes and platform != .web and !isolated); const revive = live and t.cwd.len <= effect_path_cap; const restored = try panes.Terminal.restore(p, src, revive); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .cwd = .from(t.cwd) } }); break :terminal restored; }, .file => try panes.File.restore(p, i, src), .image => restore_image: { const im = src.image.?; if (panes.Pdf.isPath(im.path)) { var raw: []u8 = if (im.bytes_b64.len > 0) try dump.decodeBytes(gpa, im.bytes_b64) else &.{}; errdefer if (raw.len > 0) gpa.free(raw); if (comptime pdf_enabled) { if (panes.Pdf.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { if (raw.len > 0) gpa.free(raw); raw = &.{}; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); break :restore_image restored; } } // If the source path is unavailable, retain its bytes // as an ordinary file rather than dropping the pane. const path = try gpa.dupe(u8, im.path); errdefer gpa.free(path); const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); raw = &.{}; errdefer if (content.len > 0) gpa.free(content); const history = try panes.File.History.create(gpa); errdefer gpa.destroy(history); const restored = try p.newDocPane(i); restored.file = .{ .path = path, .content = content, .history = history }; restored.cur_pinned = true; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true, .mode = .baseline_disk } }); break :restore_image restored; } break :restore_image try panes.Image.restore(p, i, src); }, }; p.restoreDumpTail(pane, src); pane.vweight = src.vweight; pane.collapsed = src.collapsed; } p.ncol = @min(st.columns.len, MAX_COLS); p.col_serial = @splat(0); if (st.topbar_custom) |bar| _ = p.global_tag.set(bar); for (st.columns[0..p.ncol], 0..) |col, c| { if (col.tag) |bar| _ = p.col_tags[c].set(bar); const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit)); const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); p.col_n[c] = @min(col.panes.len, MAX_PANES); for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_panes[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); colors.finishThemeInitialization(p); p.sync(); p.presentation.enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture try p.fs.log.buf.ensureTotalCapacityPrecise(p.gpa, p.fs.log.cap); ctlfs.events.announce(p); return p; } fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { if (src.tag_tail) |tail| { if (!pane.appendTag(tail)) return; pane.tag_init = true; return; } const tail_class = tailClass(pane); if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) { if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix| return p.restoreTailAt(pane, src.tag, prefix, .generic); } if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; if (std.mem.startsWith(u8, src.tag, legacy)) return p.restoreTailAt(pane, src.tag, legacy, .terminal); } const current = p.tagPrefix(pane) catch return; if (std.mem.startsWith(u8, src.tag, current)) return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); if (src.kind != .image) return; const saved = src.image.?; if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| return p.restoreTailAt(pane, src.tag, legacy, .generic); if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| p.restoreTailAt(pane, src.tag, legacy, .generic); } fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { const pfx = p.tagPrefix(pane) catch return; const class = tailClass(pane); p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); } const TailClass = enum { generic, file, output, terminal }; fn tailClass(pane: *const Pane) TailClass { if (pane.file) |f| return if (f.output == null) .file else .output; if (pane.isTerminal()) return .terminal; return .generic; } fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { if (class == .generic or class == .terminal) return live; if (!std.mem.startsWith(u8, saved_tag, live)) return live; const marked_len = live.len + dirty_marker.len; if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker)) return saved_tag[0..marked_len]; return live; } fn restoreTailAt( _: *Pardes, pane: *Pane, saved_tag: []const u8, pfx: []const u8, class: TailClass, ) void { if (!std.mem.startsWith(u8, saved_tag, pfx)) return; const rest = saved_tag[pfx.len..]; const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, .output => &.{ locations_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_close_last_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail, pre_column_file_pane_tail, pre_column_pane_tail, pre_collapse_file_pane_tail, pre_collapse_pane_tail, }, .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; if (!pane.appendTag(rest)) return; pane.tag_init = true; } /// Close a pane; its rows go to `to`, or where layout.absorbVWeight /// sends them when that is null. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { const pane = p.panes[id] orelse return; // A column outlives its panes. Closing the last one leaves an empty // pane standing in its place and in its directory; only Delcol ever // takes a column away. const place = layout.findPane(p, id); const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false; var dir_buf: [limits.host_path_cap]u8 = undefined; var dir: []const u8 = ""; if (last_in_column) { const had = paneDir(pane); const n = @min(had.len, dir_buf.len); @memcpy(dir_buf[0..n], had[0..n]); dir = dir_buf[0..n]; } // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); // ...and after it, so the stand-in inherits none of the closing pane. const heir: ?usize = heir: { if (!last_in_column) break :heir null; const f = place.?; const free = p.freeSlot() orelse break :heir null; _ = p.newScratchIn(free, dir) catch break :heir null; layout.insert(p, f.col, f.idx + 1, free); break :heir free; }; layout.absorbVWeight(p, id, to); layout.removePane(p, id); p.panes[id] = null; if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse { p.quit = true; p.emit(.quit); return; }; } pub fn removeColumn(p: *Pardes, id: usize) !void { const place = layout.findPane(p, id) orelse return; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; const count = p.col_n[place.col]; for (0..count) |i| { ids[i] = p.col_panes[place.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; } if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { p.quit = true; p.emit(.quit); return; }; } /// 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; } pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.njumps; while (i > 0) { i -= 1; const id = p.jumps[i].pane; if (id != closing and p.panes[id] != null) return id; } for (p.panes, 0..) |slot, k| { if (slot != null and k != closing) return k; } 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 const pane = p.panes[j.pane] orelse continue; if (pane.serial != j.serial) continue; p.jumps[w] = j; 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.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, .col = if (pane.pdfPage() != null) 0 else if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) 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; if (p.njumps == 0 or next < 0 or next >= p.njumps) return; const j = p.jumps[@intCast(next)]; if (j.pane >= MAX_PANES) return; const pane = p.panes[j.pane] orelse return; if (pane.serial != j.serial) return; if (p.panes[p.active]) |source| p.clearNavigationSelection(source); if (p.active != j.pane) p.clearNavigationSelection(pane); p.jcur = @intCast(next); p.focusPaneLine(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. pub fn sync(p: *Pardes) void { if (p.header_column != null and p.columnBarHeight() == 0) { p.exitHeader(); } p.reapPanes(); layout.compute(p); p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; if (pane.tag_edit and pane.prompt == .none and (id != p.active or p.topbar_col != null)) exitTagEdit(pane); 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]; const cols = @max(1, r.w -| config.GUTTER); const rows = @max(1, r.h -| BOX_H); // the tag steals the top row if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) { // 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.cols = cols; pane.rows = rows; if (pane.file) |*file| file.syntax_dirty = true; } panes.Terminal.releasePendingCommandIfReady(p, id, pane); if (pane.greet and pane.isTerminal() and p.resize_count > 0 and panes.Terminal.promptInputReady(pane)) { 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; } /// Whether an idle frontend should schedule another tick. All frame-based /// core work belongs in this one predicate so TTY and GUI mature it alike. pub fn animationActive(p: *const Pardes) bool { if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true; for (p.panes) |slot| if (slot) |pane| { if (Messages.messagesAnimating(pane)) return true; }; const scene = p.settings.scene_effects; if (scene.crt or scene.ripple or scene.glitch) return true; return p.presentation.animating(); } 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.cols == 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.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)); } 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, r: Rect) u16 { return if (p.settings.tag_bottom) r.y else r.y + BOX_H; } fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit { for (&p.surface.body_layers) |*layer| { if (layer.rows == 0 or layer.serial != body_hit.serial) continue; return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); } return null; } // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { panes.File.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { const cells = try p.gpa.alloc(Cell, ncells); p.gpa.free(s.cells); s.cells = cells; } s.cols = p.screen_w; s.rows = p.screen_h; s.cursor = null; s.pointer_shape = .arrow; if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| { if (preview.probe.link != null and preview.pane < p.panes.len) { if (p.panes[preview.pane]) |pane| { if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane)) s.pointer_shape = .link; } } }; // A look-hover preview means the pointed word is a real Look or Exec // operand: the same plumbing a click would act on. Hosts that model // the pointer per-text (the macOS I-beam) read this as "target" and // afford it the arrow instead. if (p.look_hover_preview != null) s.pointer_shape = .target; // Stale hover bits must not survive into a frame whose pointer has // moved on: the ordinary content paint does not clear fields it // never set. for (s.cells) |*c| c.hover = false; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.cols = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; s.previous_body_layers = &.{}; s.previous_tag_layers = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // Separators have their own role; a scrollbar need not frame the page. s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; pane.body_rows = 0; pane.body_visible_rows = 0; pane.context_row_limit = null; s.body_layers[id].rows = 0; Messages.collectNotices(p, pane, p.rects[id], id); try p.renderPane(arena, pane, p.rects[id], id, id == p.active); try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; if (pane.notices.len == 0) continue; const r = p.rects[id]; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; // Painted the way the sticky context headers above them are: the // tagline font and the tagline's own colours, so a notice reads as // a band belonging to the pane rather than as a line of body text // that happens to sit at the bottom of it. const msg_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }; // An OVERLAY over the body's top rows, at the right edge, one // chip to a row in the order collectNotices chose -- not a row // taken out of the body the way a tree-sitter context header is. // The chip is as wide as its own message and no wider, so the text // beneath the rest of the row keeps reading as the file it is. const first = p.bodyTop(r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const row = first + @as(u16, pane.notices.row[i]); // Every notice is painted on the canonical grid as well as // emitted as a tag layer: the grid is what a terminal client // draws, and a band it cannot see is a message it never gets. // The GUI skips grid cells a tag layer covers, so nothing is // drawn twice there. const text = Messages.noticeText(p, pane, i, &leader_buf); const chip = Messages.noticeCols(p, text, tw); if (chip == 0) continue; const cx = tx + tw - chip; pane.notices.left[i] = cx; // A message coming or going blends toward the page it sits // on; a grid cannot move it by less than a row. var style = msg_style; if (Messages.noticeLife(pane, i)) |life| { // No page colour to fade to (a terminal's own default): // the text dissolves into its chip instead. const alpha = Messages.messageMotion(p, life).alpha; const page = th.bg orelse chrome.tag_bg; style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) }; style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) }; } s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline }); // One column short of the chip's right edge: a prompt's caret // sits AFTER its last character the moment it opens, and flush // right would put it past the pane, off the grid, and past what // the detached wire will encode -- which drops every frame for // as long as the prompt is up. const printed = Messages.printRight(s, cx, row, chip -| 1, text, style); if (kind != .prompt or id != p.active) continue; const at = pane.promptAt() orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; const col = @as(usize, pane.tag_col); if (col < prompt0) continue; // The cursor follows the text to wherever it landed; a caret // in the part a narrow band dropped has nowhere to be. const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); if (prompt_col < printed.dropped) continue; const caret = printed.left + (prompt_col - printed.dropped); if (caret < tx + tw) s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.mode == .insert }; } } // global tagbar: full width, top row — unless a native shell has // taken its commands over, in which case the row is not ours to draw p.revealHeader(); var tb_buf: [1200]u8 = undefined; const global_bar = p.topbar(&tb_buf); const global_scroll = if (p.topbar_col != null and p.header_column == null) p.header_scroll else 0; if (p.settings.workspace_tag) { s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); _ = s.print(0, 0, s.cols, global_bar[panes.File.rawAtDisplay(global_bar, global_scroll)..], .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); } if (p.pointer_inside and p.hover_row < p.topBarHeight()) { const bar = p.topbar(&tb_buf); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), 0); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } if (p.topbar_col) |c| if (p.header_column == null) { const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c) -| global_scroll; if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = p.header_insert }; }; if (p.columnBarHeight() > 0) for (0..p.ncol) |c| { const grip = @min(config.GUTTER, p.col_w[c]); const inset = @min(TAG_TEXT_INSET, p.col_w[c]); const x = p.col_x[c] + inset; const w = p.col_w[c] - inset; if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; // A grip is muted until it is grabbed; then it lights, as a pane's // box does for the active pane, so the drag shows what it holds. const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; s.fill(p.col_x[c], p.topBarHeight(), grip, 1, grip_style); s.fill(p.col_x[c] + grip, p.topBarHeight(), inset - grip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); s.fill(x, p.topBarHeight(), w, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); const bar_scroll = if (p.topbar_col != null and p.header_column == c) p.header_scroll else 0; const tag = p.columnTag(c); _ = s.print(x, p.topBarHeight(), w, tag[panes.File.rawAtDisplay(tag, bar_scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline, }); // A carried column's pointer is aiming at a place, not a word. if (p.pointer_inside and p.drag != .column_move and p.hover_row == p.topBarHeight() and p.hover_col >= x and p.hover_col < x + w) { const bar = p.columnTag(c); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; while (col < hi and col < w) : (col += 1) { const cell = s.at(x + @as(u16, @intCast(col)), p.topBarHeight()); cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } }; if (p.topbar_col) |cur| { const bar = p.headerText(); const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; const y: u16 = if (p.header_column != null) p.topBarHeight() else 0; const anchor = p.header_anchor orelse cur; var col = panes.File.rawDisplayCol(bar, @min(cur, anchor)) -| p.header_scroll; const hi = panes.File.rawDisplayCol(bar, @max(cur, anchor)) -| p.header_scroll; while (col < hi and col < w) : (col += 1) { const cell = s.at(x + @as(u16, @intCast(col)), y); cell.style.fg = .{ .rgb = th.sel_fg }; cell.style.bg = .{ .rgb = th.sel_bg }; } const caret = panes.File.rawDisplayCol(bar, @min(cur, bar.len)) -| p.header_scroll; if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y, .bar = p.header_insert }; } try p.renderTagLayers(arena); switch (p.drag) { .border_v => |d| { if (d.cur_x < s.cols) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); } if (d.corner) |k| if (d.cur_y < s.rows) { var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; // Where the column's left edge would land, reordered or not. const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { .stay => break :rail, .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], .edge => |x| x, }; // Leave the outside insertion rail visible above compact // text layers. This clips presentation only, never reflows. if (edge == s.cols) { for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) { layer.viewport.w -|= 1; }; for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) { layer.viewport.w -|= 1; }; } // The rail runs in the seam cell left of the edge, where a // border drag's does: the edge cell itself is a gutter, which // the GUI paints as a scroll rail that swallowed the dashes. // At the window's left edge there is no seam, so it runs just // past that scroll rail instead. It takes the lit grip's // color: this is where the grabbed column lands. const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1); var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) { // Past the left edge's scroll rail the rail would cut // through column 0's grip and pane boxes: leave them whole. if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue; s.overlayDash(x, row, "╎"); s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; } }, .move => |d| if (d.cur_x < s.cols) { if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); } }, .none, .select, .tag => if (p.pointer_inside) { // hover hints on a pane's own trailing edge var c: usize = 0; while (c + 1 < p.ncol) : (c += 1) { if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); } } for (0..p.ncol) |cc| { if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; // the same seam the h-handle hit test picks (see there) if (p.seamIdxAt(cc, p.hover_row) != null) { var col = p.col_x[cc]; while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } } for (0..p.ncol -| 1) |cn| { if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; if (p.seamIdxAt(cn, p.hover_row) != null) continue; if (p.seamIdxAt(cn + 1, p.hover_row) == null) continue; var col = p.col_x[cn + 1]; while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } }, } // debug overlay: a small stats box drawn last, top-right ("Debug") if (p.settings.debug) if (p.panes[p.active]) |at| { var nlive: usize = 0; for (p.panes) |slot| { if (slot != null) nlive += 1; } var sb_off: usize = undefined; var sb_total: usize = undefined; if (at.file) |*f| { sb_off = f.scroll; sb_total = panes.File.nlines(p.gpa, f); } else if (at.pdfPage()) |page| { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { const sb = panes.Terminal.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } const effective_font = p.settings.font.effective_name.get(); const font_name = if (effective_font.len == 0) "default" else effective_font; var ov_buf: [256]u8 = undefined; const text = std.fmt.bufPrint( &ov_buf, "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta }, ) catch ""; // wide enough for the longest theme name compiled in, which is the // only row here whose width is not ours to choose const ow: u16 = 38; const oh: u16 = 10; const ox: u16 = if (s.cols > ow) s.cols - ow else 0; if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) { // vaxis single_rounded border ring, default style s.set(ox, p.topBarHeight(), "╭", .{}); s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); s.set(ox, p.topBarHeight() + oh - 1, "╰", .{}); s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{}); var bx = ox + 1; while (bx < ox + ow - 1) : (bx += 1) { s.set(bx, p.topBarHeight(), "─", .{}); s.set(bx, p.topBarHeight() + oh - 1, "─", .{}); } var by = p.topBarHeight() + 1; while (by < p.topBarHeight() + oh - 1) : (by += 1) { s.set(ox, by, "│", .{}); s.set(ox + ow - 1, by, "│", .{}); } s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } }); var lines = std.mem.splitScalar(u8, text, '\n'); var ly: u16 = p.topBarHeight() + 1; while (lines.next()) |line| : (ly += 1) { if (ly >= p.topBarHeight() + oh - 1) break; _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } }); } } }; try p.presentation.submit(p, s); return p.composeAsciiTransitions(arena, s); } fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { _ = p; if (!canonical.hasPanelDiff()) return canonical; var presented: ?*Surface = null; for (canonical.panelTracks()) |track| { if (!track.effect.composedByCore() or track.phase == .closing) continue; const area = layout.CellArea.of(track.to); const col_end = @min(canonical.cols, area.x0 +| area.cols); const row_end = @min(canonical.rows, area.y0 +| area.rows); var row: u16 = area.y0; while (row < row_end) : (row += 1) { var col: u16 = area.x0; while (col < col_end) : (col += 1) { const index = @as(usize, row) * canonical.cols + col; const composed = composedCell(canonical, track, area, col, row, index) orelse continue; if (composed.visuallyEqual(&canonical.cells[index])) continue; if (presented == null) { const next = try arena.create(Surface); next.* = canonical.*; next.cells = try arena.dupe(Cell, canonical.cells); presented = next; } presented.?.cells[index] = composed; } } } return presented orelse canonical; } fn composedCell( canonical: *const Surface, track: layout.Track, area: layout.CellArea, col: u16, row: u16, index: usize, ) ?Cell { if (track.effect == .ascii) { const diff = switch (canonical.cell_diffs[index]) { .ascii => |diff| diff, .unchanged, .visual => return null, }; const byte = diff.byteAt(track.frame); if (byte == diff.to) return null; if (track.frame == 0) return canonical.previous_cells[index]; return withByte(canonical.cells[index], byte); } return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) { .old => canonical.previous_cells[index], .byte => |byte| withByte(canonical.cells[index], byte), // Churn belongs on the glyph, not on the pane's empty space, and it // borrows whichever side of the diff actually has one. .churn => |byte| blk: { const new_cell = &canonical.cells[index]; if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte); const old_cell = &canonical.previous_cells[index]; if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte); break :blk old_cell.*; }, .at => |offset| blk: { const source_col = @as(i32, col) + offset.cols; const source_row = @as(i32, row) + offset.rows; // A glyph whose source lies outside the pane has not arrived // yet: the frozen old cell is the only truthful thing under it. if (source_col < area.x0 or source_row < area.y0 or source_col >= @as(i32, area.x0) + area.cols or source_row >= @as(i32, area.y0) + area.rows or source_col >= canonical.cols or source_row >= canonical.rows) break :blk canonical.previous_cells[index]; const source = @as(usize, @intCast(source_row)) * canonical.cols + @as(usize, @intCast(source_col)); break :blk canonical.cells[source]; }, }; } fn withByte(cell: Cell, byte: u8) Cell { var out = cell; out.text[0] = byte; out.len = 1; out.default = false; return out; } /// Whether a cell shows a glyph a character effect can work on. An unpainted /// cell and a space are both blank space, whatever style they carry. fn hasGlyph(cell: *const Cell) bool { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const chrome = p.chromeTheme(); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; const tag = try p.tagText(arena, pane); const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), panes.File.displayWidth(tag) + 1))); const layer = &p.surface.tag_layers[id]; if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); const storage = try arena.alloc(Cell, @as(usize, columns) + TAG_TEXT_INSET); @memset(storage, .{}); var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = 1, .cells = storage }; std.mem.swap(Surface, &p.surface, &temporary); p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = 1 }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); @memcpy(layer.cells, storage[TAG_TEXT_INSET..]); layer.kind = .pane; layer.id = @intCast(id); layer.serial = pane.serial; layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; layer.cols = columns; layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; layer.cursor = if (p.topbar_col == null and pane.promptAt() == null and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null; } var text_buf: [1200]u8 = undefined; // With the workspace tag row handed to native chrome there is no // workspace layer to render; the menu bar carries its commands. if (p.settings.workspace_tag) try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, p.topbar(&text_buf), chrome.tag_bg, chrome.tag_fg); // Each notice is its own tagline-shaped chip over the top of the body. // Going through the same layer machinery the pane and column tags use // is what gives it the tagline's height, its small-font metrics, the // band offset and the border -- none of which a body-grid row can have // just by wearing a tagline font. The geometry is the one the grid // pass already chose and recorded, so the two cannot disagree and the // GUI's "skip the cells a tag layer covers" leaves no stub behind. // A prompt is one of them too: left on the grid, a pixel shell drew // its tagline glyphs one to a BODY cell, spaced out like a banner. for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h <= BOX_H) continue; const first = p.bodyTop(r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const text = Messages.noticeText(p, pane, i, &leader_buf); if (text.len == 0) continue; const cx = pane.notices.left[i]; if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; const chip = r.x + r.w - cx; // Right aligned inside the chip, a blank cell short of its // edge: the same one the grid pass leaves for a prompt caret. // Wider than the chip, the text loses its head, as on the grid. const room = p.tagCapacity(chip) -| 1; const shown = panes.File.displayWidth(text); var kept = text; if (shown > room) { var start = panes.File.rawAtDisplay(text, shown - room); if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); kept = text[start..]; } const pad = room -| panes.File.displayWidth(kept); const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); // A pixel shell slides the message in as it fades up, and // fades it on the way out. const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; const fade = motion.alpha; const page = p.theme().bg orelse chrome.tag_bg; const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), .{ .x = cx, .y = first + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H, }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); p.surface.tag_layers[layer_index].slide = motion.slide; p.surface.tag_layers[layer_index].fade = fade; // The caret rides the band, where the prompt's glyphs are, // at the same place in the text the grid pass puts it. if (kind != .prompt or id != p.active) continue; const at = pane.promptAt() orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; if (pane.tag_col < prompt0) continue; const typed = panes.File.displayWidth(text[0..@min(pane.tag_col - prompt0, text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) continue; p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.mode == .insert }; } } if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { if (p.col_w[column] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight(), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, p.columnTag(column), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); }; } fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { _ = arena; const layer = &p.surface.tag_layers[index]; if (viewport.w == 0) { layer.cols = 0; return; } const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); layer.kind = kind; layer.id = id; layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; layer.bg = bg; layer.cursor = null; layer.slide = 0; layer.fade = 1; var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // A notice band carries a PANE id, not a column index, so it must not // answer to the column being edited -- that would scroll and select // its text with the column tag's offsets and give it a second caret. const editing = p.topbar_col != null and switch (kind) { .workspace => p.header_column == null, .column => p.header_column == id, .pane, .notice => false, }; const scroll = if (editing) p.header_scroll else 0; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); if (editing) { const cursor = p.topbar_col.?; const anchor = p.header_anchor orelse cursor; var col = panes.File.rawDisplayCol(text, @min(cursor, anchor)) -| scroll; const end = panes.File.rawDisplayCol(text, @max(cursor, anchor)) -| scroll; while (col < end and col < columns) : (col += 1) { surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } const caret = panes.File.rawDisplayCol(text, cursor) -| scroll; if (caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = p.header_insert }; } else if (p.pointer_inside) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| (if (pointer.kind == kind and pointer.id == id) pointer.col else null) else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) p.hover_col - viewport.x else null; if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; while (cell < end and cell < columns) : (cell += 1) { surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } }; } } fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); if (r.w <= config.GUTTER or r.h == 0) return; const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.GUTTER; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = p.bodyTop(r); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; // text area resets to terminal-default cells (vaxis clear semantics); // light themes paint the page over it. { // Two full passes over every cell in the pane, every frame. const tz_clear = tracy.zone(@src(), "paneClear"); defer tz_clear.end(); s.clearRect(tx, r.y, tw, r.h); if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); } const box_bg = if (active) chrome.box else chrome.box_dim; const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg }, // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style); const box_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; s.set(r.x, tag_y, switch (box_mode) { .normal => config.tag_normal, .insert => config.tag_insert, .tty => config.tag_tty, }, box_style); try p.paintPaneTag(arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); // Folded panes retain their body state, but neither body pixels nor // its cursor belong outside the one-row tag rectangle. if (pane.collapsed) return; if (comptime pdf_enabled) if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; if (pane.image) |*iv| { // Below the notice chips: a picture is drawn after the cells (the // GUI's image pass, kitty's z-order), so a chip over it would be // painted out. Text gets the overlay; a picture gives up the rows. const shown = pane.notices.span(); const image_h = (r.h -| BOX_H) -| shown; panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it, and like that one it runs // past the notice bands so the gutter has no notch in it const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; s.fill(r.x, rail_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, rail_y, 1, r.h -| BOX_H, .{ .bg = pane_bg }); return; } try body_layer.renderBody(p, arena, pane, r, id, active, true); } fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + @min(TAG_TEXT_INSET, r.w); const tw = r.w -| TAG_TEXT_INSET; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = p.bodyTop(r); const page_rgb = th.bg orelse th.tag_bg; // Focus is visible along the tag without changing its live text grid. const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), BOX_H, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); s.fill(tx, tag_y, tw, BOX_H, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const tag = try p.tagText(arena, pane); const prompt_at = pane.promptAt(); if (active and pane.tag_edit and prompt_at == null) { const caret = panes.File.rawDisplayCol(tag, pane.tag_col); var scroll: usize = pane.tag_scroll; if (caret < scroll) scroll = caret; if (caret >= scroll + capacity) scroll = caret - capacity + 1; var raw = panes.File.rawAtDisplay(tag, scroll); scroll = panes.File.rawDisplayCol(tag, raw); if (caret >= scroll + capacity) { raw = modal.nextGrapheme(tag, raw); scroll = panes.File.rawDisplayCol(tag, raw); } pane.tag_scroll = @intCast(scroll); } else if (!pane.tag_edit or prompt_at != null) pane.tag_scroll = 0; const tag_cut = if (prompt_at) |at| @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; const tag_start = @min(tag_cut, panes.File.rawAtDisplay(tag, pane.tag_scroll)); _ = s.print(tx, tag_y, tw, tag[tag_start..tag_cut], tag_style); if (tagTintRange(pane, tag, tag.len - curTail(pane).len)) |name| { const lo = @max(name.lo, tag_start); const hi = @min(name.hi, tag_cut); if (lo < hi) { var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; while (col < end and col < tw) : (col += 1) s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; } } if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); var col = panes.File.rawDisplayCol(tag, b.lo) -| pane.tag_scroll; const hi = modal.nextGrapheme(tag, b.hi); const end = panes.File.rawDisplayCol(tag, hi); while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } // cursor while editing the tag: byte offset mapped to its display cell if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) const col = panes.File.rawDisplayCol(tag, pane.tag_col) -| pane.tag_scroll; if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= BOX_H) continue; body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } } 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; p.runBuiltin(.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.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.mode = .normal; pane.cur_pinned = true; pane.cur_row = 0; pane.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); p.enterTagEdit(pane, 0); 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); Pardes.exitTagEdit(pane); pane.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.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(Pane.Mode.tty, sp.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, .{ .search = "/" }); _ = try p.render(frame.allocator()); const r = p.rects[id]; const cursor = p.surface.cursor orelse return error.MissingPromptCursor; const row = p.bodyTop(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.cols != 0 and layer.kind == .notice) break layer; } else return error.NoPromptBand; try std.testing.expectEqual(row, band.viewport.y); const caret = band.cursor orelse return error.MissingBandCaret; try std.testing.expect(caret.bar); try std.testing.expectEqualStrings("/", band.cells[caret.col - 1].grapheme()); // 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 stillborn pane gives its column to the first pane that joins it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); defer p.deinit(); try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); const blank = p.active; p.sync(); // Tty splits by the rects a frame has laid out try std.testing.expect(p.executeBuiltinLine(blank, "Tty")); const shell = p.active; p.update(.tick); // the step ends: the placeholder goes try std.testing.expect(p.panes[blank] == null); try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]); try std.testing.expectEqual(shell, p.active); // Typed into, body or tag, it is a pane like any other. try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); const typed = p.active; p.sync(); for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); try std.testing.expect(p.executeBuiltinLine(typed, "Tty")); p.update(.tick); try std.testing.expect(p.panes[typed] != null); try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]); try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); const tagged = p.active; p.sync(); p.seedTail(p.panes[tagged].?); try std.testing.expect(p.panes[tagged].?.appendTag(" make")); try std.testing.expect(p.executeBuiltinLine(tagged, "Tty")); p.update(.tick); try std.testing.expect(p.panes[tagged] != null); } 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(); 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. 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.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); // 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 "closing the last pane in a column leaves an empty one in its place" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); defer p.deinit(); const shell = p.active; p.newScratchColumn(shell); 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]); const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice()); defer std.testing.allocator.free(had); try p.removePane(lone, null); p.sync(); // The column outlives the pane: still two columns, and the one that was // emptied holds a fresh empty pane in the same directory. try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); const heir = p.col_panes[at.col][0]; try std.testing.expect(heir != lone); try std.testing.expect(p.panes[lone] == null); const hp = p.panes[heir].?; try std.testing.expectEqualStrings("", hp.file.?.content); try std.testing.expectEqualStrings(had, hp.cwdSlice()); try std.testing.expectEqual(heir, p.active); try std.testing.expect(!p.quit); // ...and Delcol is what actually takes the column away. try p.removeColumn(heir); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.ncol); } 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(Pane.Mode.tty, shell.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(Pane.Mode.tty, sp.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.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.cur_row); // ...and the cursor is still on screen, which is all `.nearest` promises. try std.testing.expect(dp.cur_row >= @as(i32, @intCast(view))); try std.testing.expect(dp.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows)); // The other arm still centres: a look target, a `:NN`, a search hit. p.focusPaneLine(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(); p.spawnTty(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(Pane.Mode.tty, pane.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(Pane.Mode.tty, pane.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(); p.spawnTty(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(Pane.Mode.normal, p.panes[shell].?.mode); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); } 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" }); 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; p.runBuiltin(.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(Pane.Mode.tty, p.panes[0].?.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.mode = .tty; try std.testing.expectEqualStrings( "user@box ~/src $ grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); sp.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.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.cur_row = row; sp.cur_col = 3; sp.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(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = 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.mode = .insert; pane.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.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.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 "Look copies its operand before releasing pointer selection storage" { 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"); const operand = try p.gpa.dupe(u8, "/test.txt:3:2"); pane.sel[0] = .{ .state = .done, .source_id = 1 }; pane.pointer_selections[0] = .{ .gesture = pane.sel[0], .rows = try p.gpa.alloc(Pane.PointerRow, 0), .raw_text = operand, }; p.drag = .{ .select = .{ .id = 0, .button = config.select_button } }; p.lookAt(0, operand); try std.testing.expectEqual(@as(i32, 2), pane.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); try std.testing.expect(pane.pointer_selections[0] == null); try std.testing.expectEqual(.none, pane.sel[0].state); try std.testing.expectEqual(.none, p.drag); } test "Look ignores missing and out of bounds pane addresses" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("alpha\nbravo\n"); for ([_][]const u8{ config.pane_addr ++ "15:2", config.pane_addr ++ "999999:2" }) |operand| { pane.setRange(pane.file.?.content, 0, .{ .anchor = 0, .head = 3 }, true); p.lookAt(0, operand); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expect(!pane.vsel.active); } } 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.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.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.vsel.active and !pane.msel.active and !pane.select); try std.testing.expectEqual(@as(u8, 0), pane.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.cur_row); try std.testing.expectEqual(@as(i32, if (index == 0) 7 else 0), pane.cur_col); } pane.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); const selected = pane.primaryRange(pane.file.?.content, 0); p.jumpBy(1); // No forward entry: selection stays untouched. try std.testing.expectEqualDeep(selected, pane.primaryRange(pane.file.?.content, 0)); 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.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 "mouse thumb buttons navigate once and clear selection gestures" { 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"); pane.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); p.njumps = 3; p.jcur = 2; for (0..3) |i| p.jumps[i] = .{ .pane = 0, .serial = pane.serial, .line = @intCast(i + 1), .col = 1 }; p.drag = .{ .select = .{ .id = 0, .button = config.select_button } }; p.update(.{ .mouse = .{ .button = .back, .kind = .press, .col = 0, .row = 0 } }); try std.testing.expectEqual(@as(usize, 1), p.jcur); for ([_]Mouse.Kind{ .drag, .release, .motion }) |kind| p.update(.{ .mouse = .{ .button = .back, .kind = kind, .col = 0, .row = 0 } }); try std.testing.expectEqual(@as(usize, 1), p.jcur); p.update(.{ .mouse = .{ .button = .forward, .kind = .press, .col = 0, .row = 0 } }); p.update(.{ .mouse = .{ .button = .forward, .kind = .release, .col = 0, .row = 0 } }); try std.testing.expectEqual(@as(usize, 2), p.jcur); try std.testing.expect(!pane.vsel.active); try std.testing.expectEqual(.none, p.drag); }