//! The pardes core: a text environment as a library, the way ghostty-vt is a //! library. The platform shell owns the event loop and process-facing IO; it //! feeds this core events (input, pty bytes, resizes) and reads back two plain values: //! a Surface — the canonical cell-grid interface, which the tty shell hands to //! vaxis nearly verbatim and the SDL shells rasterize — and a queue of Effects, //! the IO the core wants performed (spawn a shell, write a pty, open a link). //! Path-backed document and search operations may read synchronously; work //! which needs a host or event loop is emitted as an Effect. //! //! Platform divergence inside the core is the `platform` comptime tag, used //! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig. //! //! src/ layout: the core lies flat at src/, and every SUBDIRECTORY is one //! backend (tty/ gui/ lsp/) — so a file being in no directory at all is what //! says it is core, and nothing needs a header to claim it. //! //! Not one key, button or piece of Look syntax is spelled in this file: every //! one of them is a named binding in config.zig, and `hit`/`isPrefix` below are //! the only two matchers. That is so retargeting anything is an edit in one //! file, and so a later builtin can enumerate the bindings the way Help already //! enumerates the leader. const std = @import("std"); pub const animation = @import("animation.zig"); pub const panel_animation = @import("panel_animation.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); const mvzr = @import("mvzr"); const modal = @import("modal.zig"); const normal_input = @import("normal_input.zig"); const look = @import("look.zig"); pub const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); const term_pane = @import("term_pane.zig"); const file_pane = @import("file_pane.zig"); const image_pane = @import("image_pane.zig"); pub const pdf_pane = @import("pdf_pane.zig"); const output_pane = @import("output_pane.zig"); const builtins = @import("builtins.zig"); const runtime_cfg = @import("runtime_config.zig"); const selection_pipe = @import("selection_pipe.zig"); /// acme's control filesystem, as a pure transaction over this core: the FILES /// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The /// transport that carries requests in is a host's business (src/fuse.zig). pub const acmefs = @import("acmefs.zig"); pub const config = @import("config.zig"); pub const pdf_enabled = pdf_pane.enabled; pub const pdf = pdf_pane.pdf; pub const allocators = @import("allocators.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); /// The host seam: one struct of optional function pointers, with in-core /// defaults for every method a host leaves null. See src/host.zig. const host_mod = @import("host.zig"); pub const Host = host_mod.Host; pub const Fanout = host_mod.Fanout; pub const Fallback = host_mod.Fallback; pub const fallback_dump_path = host_mod.fallback_dump_path; /// Tracy's frame boundary, re-exported so a host that is not a shell — the /// fling benchmark — can delimit the same frames the tty loop delimits without /// reaching around the core for src/tracy.zig and its build options. A no-op /// unless -Dtracy names a Tracy checkout. pub const frameMark = tracy.frameMark; /// `p4` is ESP32-P4 firmware: a riscv32-freestanding core whose whole host is /// a serial line. It joins `web` in having no filesystem, no ptys and no /// config directory, which is what `hosted` below is for. pub const Platform = enum { tty, gui, web, macos, p4 }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); /// A build with no host but its display: the embedded source filesystem, the /// in-process clipboard, silent ptys. Comptime, and its own option module /// rather than a `pardes_config` field, because it is the one setting that /// produces a SECOND executable from the same graph — see `run-isolated`. pub const isolated = @import("pardes_isolation").isolated; /// Frontends that draw their own text, and can therefore be told which face to /// wear. On the tty the font belongs to the terminal emulator and in the /// browser it belongs to the page, so there the Font builtins are not /// disabled so much as meaningless — see builtins.zig. pub const font_picker = platform == .gui or platform == .macos; /// Platforms whose host is a real operating system: a filesystem to open, a /// pty to fork, a config directory to watch. The browser and the P4 firmware /// have none of the three, and every gate that used to read `platform != .web` /// reads this instead so a third such platform cannot forget one of them. pub const hosted = platform == .tty or platform == .gui or platform == .macos; /// Builds that HAVE terminal panes: a pane whose content is a live ghostty-vt /// emulator being fed pty bytes. The P4 firmware has no processes, no ptys and /// nothing that could produce a VT byte, so there the emulator is ~400 KiB of /// flash and a PageList of RAM spent parsing input that cannot arrive — and it /// drags a pile of freestanding root hooks in behind it (os.PATH_MAX, /// os.heap.page_allocator, a cwd handle), none of which the core itself wants. /// False means ghostty-vt is not in the module graph at all: build.zig never /// even asks for the dependency. /// /// A PLATFORM gate and deliberately NOT one derived from the target: `web` is /// freestanding too and KEEPS the emulator, because the browser shell renders /// terminal panes back out of a replayed dump. Every gate in the core keys off /// this one name, and src/term_pane.zig re-exports it as `enabled` and owns /// the whole seam — the two Pane slots included — so ghostty-vt ends up /// imported by exactly one file. pub const terminal_panes = platform != .p4; /// ...and the one fact about that face the core keeps: the name `Font` last /// resolved, which the Debug overlay prints. Behind the same comptime shim /// builtins.zig and macos.zig import this file with, so a tty or web binary /// never analyses a font-directory walk it cannot use. const fonts = if (font_picker) @import("fonts.zig") else struct {}; /// Native PDF quality is a shell property, but the core owns MuPDF and the /// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text /// quality and asks the renderer to cover either fit axis without upscaling. pub const PdfRasterPolicy = pdf_pane.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, .p4 => kitty_pdf_raster_policy, }; // The capacities and the two heights that are STRUCTURE, not taste: the // fixed-size pane/column arrays, and the fact that the topbar and a tag are // one row each (nothing here works at any other value). The layout numbers // that ARE taste — the gutter, the line-number prefix, scrolloff, the pane // minimums — live in config.zig with everything else a user retargets. pub const MAX_PANES = 16; pub const PDF_PAGE_GAP_PX = pdf_pane.page_gap_px; pub const MAX_COLS = 6; /// 32 fractional bits leave ample precision for resize/restored ratios while /// allowing every possible column split to divide an initial weight exactly. const column_weight_unit: u64 = 1 << 32; const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; /// how far back the jump stack remembers. Vim keeps 100; this is a session of /// at most sixteen panes, so the depth that matters is "more visits than you /// can hold in your head" and the oldest entry falls off the bottom. pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; /// A place the keyboard has been: a pane AND a spot in it, which is the whole /// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now /// rewind WITHIN a pane, and a Jumplist row can name a line. /// /// It is SAFE against the pane it names dying: `serial` is the pane's own /// identity, so an entry whose slot has since been handed to a different pane /// reads as dead rather than silently retargeting itself at the newcomer, and /// sync() drops it. What it does not do is outlive the pane — ponytail: a /// location is a place in the SESSION, not on disk, so closing a file forgets /// the entries pointing into it. To make Ctrl-o RE-OPEN a closed file, this /// grows a path field and jumpBy looks it when the pane is gone. pub const Loc = struct { pane: u16, serial: u32, /// 1-based, both — this is the `path:LINE:COL` a look word spells, and /// focusPaneLine takes exactly these. 0 = no spot, just the pane (see /// trackJump: a shell whose cursor is still the program's). line: u32, col: u32, }; test { _ = @import("pdf_pane_integration_test.zig"); _ = @import("output_pane_integration_test.zig"); } const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; // Kept separate from the path so a click still expands to the exact filename. // It belongs to the live, read-only prefix rather than the editable command // tail: saving removes it without rewriting anything the user typed there. const dirty_marker = " *"; // Version-1 dumps originally persisted only the whole rendered tag. These were // the two canonical tails before New joined every pane; the compatibility // parser recognizes them as defaults while new dumps carry an explicit tail. 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"; // Builtins: executing the name (middle-click / Tab) runs it through the ONE // dispatcher (runBuiltin, reached from execute), no matter where the name // appears — and Look and Exec are two of them, so the click itself is a // builtin. One STRUCT per builtin in builtins.zig — name, comment and // body in one place — and this enum is folded out of THAT FILE'S declarations // at comptime, so the enum FIELD NAME is still the user-visible word (the one // in the topbar, the one sitting in a tag, the one Help prints, the one you // execute) and `std.meta.stringToEnum` is still the lookup with no name table // to keep in sync. It lands here rather than in builtins.zig because a // container cannot hold a decl folded out of its own decl list, and here it // sits with the other two comptime folds (builtin_rows, the topbar check). const Builtin = builtins.registry.Builtin(); /// The PDF integration suite lives beside the PDF implementation instead of /// making the core's first sixteen hundred lines pane-specific. These direct /// test-only calls reach the few intentionally private core transactions that /// the suite must observe; the declaration is empty in every non-test build. pub const pdf_test = 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 nextPipeEffect(p: *Pardes) ?u32 { while (p.nextEffect()) |effect| switch (effect) { .pipe => |request| return request.id, else => {}, }; return null; } /// Perform every queued effect through the in-process host — what a real shell /// does with the drain — and report the path the last `.save_text` among them /// asked for, copied out of the effect into `buf`. 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; } test "selection pipe prompt submits exact request and Escape cancels" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.hxOpenFileContent("one\ntwo\n"); pane.cur_row = 0; pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); try std.testing.expect(pane.tag_edit and pane.hasPipePrompt()); try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker)); try std.testing.expect(nextPipeEffect(p) == null); p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqualStrings("tr a-z A-Z", request.command); try std.testing.expectEqualStrings("/", request.cwd); try std.testing.expectEqual(@as(usize, 1), request.inputs.len); try std.testing.expectEqualSlices(u8, "one", request.inputs[0].bytes); const before = pane.file.?.content; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); try std.testing.expectEqualSlices(u8, before, pane.file.?.content); try std.testing.expect(nextPipeEffect(p) == null); } 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.hxOpenFileContent(long ++ "\nsecond\n"); p.settings.wrap = true; const width = file_pane.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 "selection pipe replaces all ranges atomically and undo restores them" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.hxOpenFileContent("aa bb cc\n"); const pl = try p.paneCursorLines(pane); const ranges = [_]modal.HxRange{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqual(@as(usize, 2), request.inputs.len); try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); const outputs: []const []const u8 = &.{ "AA\n", "" }; p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA\n bb \n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); try std.testing.expectEqual(@as(u8, 1), pane.nsel); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content); } test "selection pipe failure and stale completion never mutate the file" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.hxOpenFileContent("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } }); try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n")); p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } }); try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); } test "selection pipe rejects a reused pane slot and a superseded request" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} var pane = try p.hxOpenFileContent("old\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; pane = try p.hxOpenFileContent("new\n"); // same slot, different serial p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } }); try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const first_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const latest_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; try std.testing.expect(first_id != latest_id); p.update(.{ .pipe_resp = .{ .id = first_id, .success = true, .outputs = &.{"BAD"} } }); try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } }); try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); } test "selection pipe binding is file-normal-only" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.hxOpenFileContent("x"); pane.file.?.output = .{ .from = .search }; p.update(.{ .key = .{ .cp = '|' } }); try std.testing.expect(!pane.hasPipePrompt()); pane.file.?.output = null; pane.mode = .insert; p.update(.{ .key = .{ .cp = '|', .text = "|" } }); try std.testing.expect(!pane.hasPipePrompt()); try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); } test "insert newline adds one indent level after a closing call" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.hxOpenFileContent(" callback({})\n"); pane.mode = .insert; pane.cur_col = 16; p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expectEqualSlices(u8, " callback({})\n \n", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); try std.testing.expectEqual(@as(i32, 8), pane.cur_col); } 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(); // The last valid command wins even after unknown, malformed, and // well-formed-but-failing lines. `Kill trailing-garbage` must not be // accepted as Kill, nor fall through to the shell from config. 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 "ThemeFile parses ZON atomically and Theme returns to the compiled ring" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .config_dir = "/tmp/pardes-config", }); defer p.deinit(); while (p.nextEffect()) |_| {} p.requestThemeFile(0, "themes/mine.zon"); const request_effect = while (p.nextEffect()) |effect| switch (effect) { .theme_file => |request| break request, else => {}, } else return error.MissingThemeFileEffect; try std.testing.expect(request_effect.on); const request = p.themeFileRequest(request_effect.generation) orelse return error.MissingThemeFileRequest; try std.testing.expectEqualStrings("/tmp/pardes-config/themes/mine.zon", request.path); var custom = themes[0]; custom.name = "mine"; custom.bg = .{ 1, 2, 3 }; custom.tag_bg = .{ 4, 5, 6 }; var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); defer encoded.deinit(); try std.zon.stringify.serialize(custom, .{ .whitespace = true }, &encoded.writer); try std.testing.expect(p.loadThemeFile(request_effect.generation, encoded.written(), false)); try std.testing.expectEqualStrings("mine", p.theme().name); try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // A half-written save is rejected without disturbing the last good value. try std.testing.expect(!p.loadThemeFile(request_effect.generation, ".{ .name = ", true)); try std.testing.expectEqualStrings("mine", p.theme().name); try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); p.setThemeIndex(0); try std.testing.expectEqualStrings(themes[0].name, p.theme().name); try std.testing.expect(p.themeFileRequest(request_effect.generation) == null); const stop = while (p.nextEffect()) |effect| switch (effect) { .theme_file => |theme_file| break theme_file, else => {}, } else return error.MissingThemeFileStop; try std.testing.expect(!stop.on); } 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 "runtime theme changes animate chrome and retarget without a jump" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const first = p.chromeTheme().*; try std.testing.expect(!p.animationActive()); const dark_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "dark")) break i; } else return error.MissingDarkTheme; const acme_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "acme")) break i; } else return error.MissingAcmeTheme; p.setThemeIndex(dark_index); // Target semantics are immediate, while the displayed chrome begins at // the exact previous frame. try std.testing.expectEqualStrings("dark", p.theme().name); try std.testing.expectEqual(first, p.chromeTheme().*); try std.testing.expect(p.animationActive()); p.update(.tick); p.update(.tick); p.update(.tick); const midflight = p.chromeTheme().*; try std.testing.expect(!std.meta.eql(first, midflight)); p.setThemeIndex(acme_index); try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expectEqual(midflight, p.chromeTheme().*); try std.testing.expect(p.animationActive()); for (0..animation.transition_steps) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // Extra ticks are inert at the exact endpoint. p.update(.tick); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); } test "render uses target content colors while chrome is in flight" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); const source_chrome = p.chromeTheme().*; const acme_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "acme")) break i; } else return error.MissingAcmeTheme; p.setThemeIndex(acme_index); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const surface = try p.render(frame.allocator()); // The anchored topbar has not advanced from its source frame. try std.testing.expectEqual( Color{ .rgb = source_chrome.tag_bg }, surface.at(0, 0).style.bg, ); const topbar_cell = surface.at(0, 0); try std.testing.expect(!std.mem.eql(u8, topbar_cell.grapheme(), " ")); try std.testing.expectEqual(FontRole.tagline, topbar_cell.style.font_role); const r = p.rects[0]; const tag_cell = find_tag_cell: { var x = r.x + config.GUTTER; while (x < r.x + r.w) : (x += 1) { const cell = surface.at(x, r.y); if (!std.mem.eql(u8, cell.grapheme(), " ")) break :find_tag_cell cell; } return error.MissingTagText; }; try std.testing.expectEqual(FontRole.tagline, tag_cell.style.font_role); try std.testing.expectEqual(FontRole.tagline, surface.at(r.x, r.y).style.font_role); try std.testing.expectEqual(FontRole.tagline, surface.at(r.x + r.w - 1, r.y).style.font_role); // The document/terminal page does not ride the chrome animation: acme's // target background is already present in the same render. const body = surface.at(r.x + r.w - 1, r.y + r.h - 1); try std.testing.expectEqual(FontRole.body, body.style.font_role); try std.testing.expectEqual( Color{ .rgb = p.theme().bg.? }, body.style.bg, ); 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.."New".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(3, 0).style.bg); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 3, .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 New 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(" Save New Newtty Del Filter", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; // A real middle-click on the canonical pane-tag word reaches Exec, which // must consume New as a builtin before any write can reach the shell. // The builtins are right-aligned (see tagGap), so the column is found in // the rendered tag rather than assumed to be at its left edge. const rendered = try p.tagText(p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 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].?.file.?.output != null); // the scratch // 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 "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.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.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 image_pane.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 "an untouched tagline ends where its layout column's widest one does" { 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 second shell BELOW the first — what Alt-n does — sitting in a // directory too long to leave tag_right_pad columns free const below_id = p.freeSlot().?; const below = try p.newShell(below_id, ""); const f = p.layoutFindTerm(p.active).?; p.layoutInsert(f.col, f.idx + 1, below_id); p.splitBelow(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(p.scratch.allocator(), above); const below_tag = try p.tagText(p.scratch.allocator(), below); // one column, so the tails end together — and past the pad, at the long // path, which is the whole point (equal at tw - tag_right_pad would prove // nothing: that is where both sat before) try std.testing.expectEqual(below_tag.len, above_tag.len); const tail = " Save New Newtty Del Filter"; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad); try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER); // A voter too wide for the pane is counted AT the pane's edge, not // dropped: dropping it is a threshold, and one column of resize either // side of the fit would move every tagline in the column by the whole pad // while you drag the window edge. One column in, one column out. p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} const fits = (try p.tagText(p.scratch.allocator(), above)).len; p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(fits - 1, (try p.tagText(p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} // Touching the widest tag freezes ITS gap and must move nobody: it goes on // voting with the end it was frozen at, however much is typed after the // builtins. (A plain click seeds the tail, so the alternative is every // other tagline in the column snapping left the moment you click one.) p.seedTail(below); 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); // A pane squeezed off the bottom is not drawn, so it stops voting and the // column falls back to the pad — 2 rows is one tagline and no room for the // second pane at all. 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.expectEqual( @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad, (try p.tagText(p.scratch.allocator(), above)).len, ); } 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(" Save New Newtty Del Filter", Pardes.curTail(terminal)); // The two preceding releases used the generic current default and then the // Filter tail without Save. Both upgrade, including any saved layout // padding. 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(" Save New Newtty Del Filter", 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(" Save New Newtty Del Filter", Pardes.curTail(terminal)); 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)); // The first dump format included tty mode in the live prefix. Its stored // cwd still identifies where the editable bytes begin after the mode word // disappeared from today's tag, so a custom tail must survive that move. 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(" Save New Newtty Del Filter", 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.hxOpenFileContent(""); 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 New Newtty 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)); // Before renderer choices appeared in the live prefix, image dumps began // with only `img PATH`. Their custom tails still migrate through the // pane-specific legacy-prefix recognizer. const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{}); try std.testing.expectEqualStrings(" New Newtty 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(p.layoutSplitColumn(0, right, false)); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current p.joinCol(); try std.testing.expectEqual(@as(usize, 1), p.ncol); const lf = p.layoutFindTerm(0) orelse return error.LostLeftPane; const rf = p.layoutFindTerm(right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); p.joinCol(); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } test "Newtty 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"); p.spawnTty(0); 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 "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.hxOpenFileContent("before\n"); const clean = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, clean, "/hxcase.txt *") == null); file_pane.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, "/hxcase.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")); const saved = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, saved, "/hxcase.txt *") == null); file_pane.changed(p, 0, "external\n"); const reloaded = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, reloaded, "/hxcase.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 }; file_pane.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, "/hxcase.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]); } /// The host's tty query, as a test double: which panes a program is holding, /// and how many times the core actually bothered to ask. The count is the /// laziness contract — nothing but a command line about to be typed may ask. const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, const vtable: Host.VTable = .{ .pull_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()); // A plain/unsupported shell has no OSC 133 B to wait for. Successful // fork acknowledgement opens the gate and the pty itself buffers input // until that child reads it. 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(); 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 = image_pane.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { p.image_gpa.free(raw); return err; }; pane.image.?.petscii = true; pane.image.?.pmode = .terminal; pane.image.?.ascii = false; try std.testing.expect(pane.appendTag(" Keep Del")); pane.tag_init = true; try p.dumpState(); const first = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); defer dump.free(gpa, first); 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.?.petscii); 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(); const redump = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); defer dump.free(gpa, redump); 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" { // The test above this pair ("unknown Exec from an image...") is the same // setup with the terminal at its prompt, and it reuses pane 0. The single // difference here is the verdict. 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()) |_| {} // /tmp rather than a made-up name: the look resolves against the real // filesystem, so the directory has to exist for this arm to be reached 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"); // A frame is a frame: rendering, typing, moving the mouse and resizing ask // nobody anything. This is the whole point of the query being a pull — the // probe it runs walks /proc, and it used to run for every pane of every // frame to answer a question only Exec and Look ever ask. _ = 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); // ...and the fallback scan asks only about the panes that could possibly // answer yes: the cwd comparison is free and comes first, so the two shells // sitting in other directories cost nothing. Pane 0 is asked a second time // because it IS on the directory the command was about. 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 = p.layoutFindTerm(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, p.layoutFindTerm(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)); } 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.hasSavePrompt()); 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.hasSavePrompt()); 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: output_pane.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(" Save New Newtty Del", Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.hasSavePrompt()); 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()) |_| {} // an argument answers the question the prompt would have asked, so no // prompt arms — and a bare name lands under the pane's directory, the way // a relative word in a look resolves, not under the process's cwd var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save session.txt")); try std.testing.expect(!pane.hasSavePrompt()); 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()); // ...and a path that cannot be made absolute is refused outright: a shell // that has not reported a directory has nothing to resolve against, and // the directory pardes was started in is not a guess worth making p.panes[0].?.cwd = .none; try std.testing.expect(p.executeBuiltinLine(0, "Save nowhere.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); } 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.hxOpenFileContent("before\n"); file_pane.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("/hxcase.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 /hxcase.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.hxOpenFileContent("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.hxOpenFileContent("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; try std.testing.expectEqualStrings(" Save New Newtty Del", 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(" Save New Newtty Del", 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 New Newtty 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 image_pane.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(!img.hasSavePrompt()); 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()) |_| {} // Enter on the bare prefill: a directory is not a file to become, and the // scratch must still be a scratch afterwards — the alternative is a buffer // renamed onto a path whose write silently failed try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(scratch.hasSavePrompt()); 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].?.vt.screens.active.cursor.y; p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); try std.testing.expectEqual(before, p.panes[0].?.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.hxOpenFileContent("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); } const RecordHost = struct { gpa: std.mem.Allocator, writes: std.ArrayListUnmanaged(u8) = .empty, /// What this host would answer a clipboard read with, and whether it was /// ever asked — a pull must reach exactly one host. clipboard: []const u8 = "", asked: usize = 0, core: ?*Pardes = null, const vt: Host.VTable = .{ .push_pty_write = ptyWrite, .pull_read_clipboard = readClipboard }; fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { _ = pane; const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); self.writes.appendSlice(self.gpa, bytes) catch {}; } fn readClipboard(ctx: ?*anyopaque) void { const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); self.asked += 1; self.core.?.update(.{ .paste = self.clipboard }); } fn host(self: *RecordHost) Host { return .{ .ctx = self, .vtable = &vt }; } }; test "a fan-out host reaches every wrapped host, each with its own state" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); defer p.deinit(); var a: RecordHost = .{ .gpa = gpa }; defer a.writes.deinit(gpa); var b: RecordHost = .{ .gpa = gpa }; defer b.writes.deinit(gpa); const wrapped = [_]Host{ a.host(), b.host() }; var fan: Fanout = .init(&wrapped); p.host = fan.host(); p.perform(.{ .write = .{ .pane = 0, .bytes = .from("echo hi\r") } }); try std.testing.expectEqualStrings("echo hi\r", a.writes.items); try std.testing.expectEqualStrings("echo hi\r", b.writes.items); // A PULL reaches ONE host, and the name is what says so. Fanned out, both // would answer and the core would paste the clipboard twice for one Ctrl-V. a.core = p; b.core = p; a.clipboard = "from-a"; b.clipboard = "from-b"; const doc = p.panes[p.active].?; doc.mode = .normal; p.perform(.read_clipboard); try std.testing.expectEqual(@as(usize, 1), a.asked); try std.testing.expectEqual(@as(usize, 0), b.asked); // a method NO wrapped host implements still falls back per-method p.perform(.{ .open_link = .from("https://example.invalid") }); try std.testing.expectEqualStrings("https://example.invalid", p.fallback.link.items); } 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 "Msg writes the transient row by hand, bare or with text, and input ends it" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const row = struct { fn of(pp: *Pardes, i: usize) []const u8 { const pane = pp.panes[i].?; return pane.msg[0..pane.msg_len]; } }.of; // the whole tail, spaces and all: the row is a sentence, not a word try std.testing.expect(p.executeBuiltinLine(2, "Msg saved /etc/hosts")); try std.testing.expectEqualStrings("saved /etc/hosts", row(p, 2)); // ...on the pane that ran it, and nowhere else try std.testing.expectEqualStrings("", row(p, 0)); // Bare, it reports ITSELF through reportError — the `: ` // every failed save and refused Look arrives in. try std.testing.expect(p.executeBuiltinLine(2, "Msg")); try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2)); // A message is exactly as old as your last input. p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expectEqualStrings("", row(p, 2)); // Longer than the row's buffer is CUT, never refused and never overrun: // this word is the only way to reach that boundary without a 300-byte path. var long: [600]u8 = @splat('x'); var line: [608]u8 = undefined; const cmd = try std.fmt.bufPrint(&line, "Msg {s}", .{&long}); try std.testing.expect(p.executeBuiltinLine(2, cmd)); try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len); } 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.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: panel_animation.Track = .{ .serial = p.panes[p.active].?.serial, .pane = @intCast(p.active), .effect = .slide, .from = .{ .w = 20, .h = 10 }, .to = .{ .x = 10, .w = 20, .h = 10 }, }; p.panel_tracks[p.active] = fake_track; p.presented_panel_tracks[p.active] = fake_track; p.panel_presentation_ready = 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(FontSizeUnit.pixels, p.settings.font.effective_size_unit); try std.testing.expect(p.panel_tracks[p.active] == null); try std.testing.expect(p.panel_presentation_pending); try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels)); // Asking for the face already on screen is still a real host round trip, // but it does not invalidate the frozen layer: its effective tuple did // not change. A rejected request likewise never reaches acknowledgeFont. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(p.executeBuiltinLine(p.active, cmd)); _ = p.takeFontRequest() orelse return error.MissingRepeatedFontRequest; p.panel_tracks[p.active] = fake_track; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(p.panel_tracks[p.active] != null); try std.testing.expect(!p.panel_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 "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(panel_animation.Transition.slide, restored.settings.panel_transition); try std.testing.expect(!restored.animationActive()); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); const initial = try restored.render(frame.allocator()); restored.acknowledgePanelPresentation(initial.panelTracks()); _ = try restored.newShell(1, ""); try std.testing.expect(restored.layoutSplitColumn(0, 1, false)); restored.sync(); try std.testing.expect(restored.panel_tracks[0] != null); try std.testing.expect(restored.panel_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); } /// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN /// declarations rather than a table beside them — a new special key names /// itself here, and a wrong name is impossible because there is only one. The /// rest is the printable character; anything else is a compile error, because /// a private-use codepoint cast to a byte would render as silent garbage in a /// listing nobody diffs. 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))}; } } /// Every way to run `b` that is NOT its leader path: the chords and buttons /// config binds to it, plus the topbar if it has a word up there (row 0 is a /// click target, and the only shortcut that works in tty mode where SPC /// belongs to the shell). Walked from config's own tables — window_keys' and /// jump_keys' `cmd` columns, look_cmd/exec_cmd, topbar_str — so retargeting a /// binding there re-renders here with nothing to keep in step. /// /// What is NOT here, and cannot be: a chord that reaches no builtin. Alt-n, /// Alt-c, the tty toggle and the 1-2/1-3 cut/paste chords are inline handlers /// with no word to index by. The day one of them becomes a builtin — a struct /// in builtins.zig and a `cmd` column beside its binding — it appears here for /// free, which is the upgrade path rather than a special case here. 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; // both spellings of the four directional moves: the arrow is a real // key someone presses, and the table carries it precisely so it is // discoverable from the builtin as well as the other way round 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; } } // the two acme verbs, a key and a mouse button each. Asked of the // BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look // and Exec by name, so pointing look_cmd at Grep moves the row. 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; } } /// THE BUILTIN INDEX, flattened: one row per builtin — the leader path that /// runs it (null: SPC does not reach it), every other way to run it, and the /// Help line those are rendered into. SORTED BY PATH, so every prefix's /// subtree is a contiguous run, which is all the two runtime readers need. The /// matcher asks "exact hit? still a prefix of something?" and Help filters the /// same rows by the same prefix. A node-and-pointer trie for forty /// two-character paths would be ceremony. /// /// A row per BUILTIN and no longer a row per leader PATH, which is what makes /// Help the complete index instead of a second builtin listing a superset of /// what Help lists. A path-less builtin (Look, Exec, Theme) is a row like any /// other and its empty key column is the information: SPC does not reach this /// one, the last column does — or nothing does, which is also worth knowing. /// The mid-chord filter is the SAME array read with a non-empty prefix, and it /// drops those rows by itself because an empty path starts with nothing. 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 ""; // the name column is only padded when something follows it: a row // whose builtin has no other shortcut ends at the name, so the listing // carries no trailing whitespace 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, }; } // insertion sort by path: a group sorts right before what extends it, and // a path-less builtin sorts after every path — DEL is not a path because // leaderKey only ever stores a printable key 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; }; // config.topbar_str is a HAND-PICKED subset of the builtins in a fixed order, // not a derivation — see it there for why each word is in or out. This is the // check that a rename cannot silently rot it. 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 }; /// The whitespace-delimited word covering `col` in `str`. The topbar uses the /// same bounds for pointer feedback and dispatch, so the word that lights up /// is necessarily the word a middle click will execute. 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 ---- /// Halfway between two colors, channel by channel. Every selection tint pardes /// paints is a mix of theme colors — the per-mouse-button ones pull the theme's /// selection toward one of its own accents, a quarter of the way and so a mix /// of a mix; the extra cursors' pulls it back toward the page — and renderPane /// makes seven of those, which is six more than spelling the same three-channel /// loop out at the call site is worth. fn mix(a: [3]u8, b: [3]u8) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); return out; } const TAG_TAIL_CAP = dump.max_tag_tail; // one editable command line; extra input is refused const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record /// How many effects the ring holds. SHRUNK, not moved to the heap, on the /// board: `pump` drains this to empty on every iteration with an /// unconditional `while (nextEffect())` — including effects `perform` itself /// queues — so no capacity can deadlock the drain, and the only question a /// capacity answers is how big a single-pump BURST may be before `emit` /// refuses the overflow. The one producer that can burst is `emitWrite`, /// which chunks arbitrary bytes into 64-byte `.write` effects for a pty, and /// a build with `terminal_panes == false` has no pty to write to. Everything /// else queues O(1) effects per event, and `in_q` holds at most 64 events per /// pump, so 128 leaves two effects per queued event. /// /// A 1.0625 MiB inline ring cannot live in the board's 384 KiB heap at all; /// 128 entries is 34 KiB. NOTE THE BEHAVIOUR CHANGE: `emit` has always /// refused (not evicted) once full, so on p4 a burst larger than 128 effects /// now drops its tail where 4096 would have held it — reachable only through /// `emitWrite`, i.e. only if a pty ever appears on this platform. const EFFECT_CAP = if (platform == .p4) 128 else 4096; /// Rows the per-pane soft-wrap map covers. `wrapWidth` refuses to wrap a pane /// taller than this (it reads the array's own length), so shrinking it cannot /// truncate a map — a taller pane renders unwrapped, exactly as documented on /// `Pane.wrap_line`. A serial console is not 128 rows tall. const WRAP_ROWS = if (platform == .p4) 128 else 256; /// A shell's reported working directory, owned inline by the pane. Zero-sized /// where there are no processes to report one: the `PdfSlot` rule, applied to /// a capacity whose sole producer (`Pardes.setCwd`, fed by a pty's prompt /// report) does not exist without terminal panes. `setOwnedCwd` clamps, so a /// zero cap reads as "no directory known" — which is the truth here. const CWD_BUF_CAP = if (terminal_panes) 1024 else 0; /// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own /// default cell showing (the native-dark shape); `palette` null = let a child's /// ANSI indices reach the host untranslated until that terminal enables its /// theme-keyed Filter. The gutter's move box lives here too: it used to be a /// pair of module constants, but a theme that wants to be /// restrained has to be able to turn the accent DOWN, and the box is the one /// piece of loud chrome on screen. The overlay/drag greys that were also /// hardcoded turned out to be the dark theme's own scroll_track/lineno/tag_fg /// spelled a second time, so they read those fields now instead. /// /// Selection is ONE pair, `sel_bg`/`sel_fg`, and the rest is arithmetic: the /// three per-mouse-button tints and the dimmed extra cursors are mixed off it in /// renderPane rather than named here. A theme author gets their selection colour /// right and would get four more by accident — and every source gen_themes reads /// names exactly one selection anyway. pub const Theme = struct { name: []const u8, bg: ?[3]u8, fg: ?[3]u8, sel_bg: [3]u8, sel_fg: [3]u8, tag_bg: [3]u8, tag_fg: [3]u8, box: [3]u8, // move box, focused pane box_dim: [3]u8, // move box, everything else kw: [3]u8, str: [3]u8, num: [3]u8, comment: [3]u8, lineno: [3]u8, scroll_track: [3]u8, scroll_thumb: [3]u8, palette: ?[16][3]u8, }; /// The three themes pardes ships with, in RING ORDER, which is the one thing /// the files themselves cannot say: `helix` is index 0 and so what boots, and /// NextColor walks from here out into the generated ones. Written here rather /// than in a fourth file because the order IS the information — and it is /// load-bearing, since test/snapshots/theme.snap captures the first three /// steps of the ring by their colors. const curated = struct { pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); }; /// A zig file IS a struct, so the FILES are the list: this walks a container's /// declarations — each one an imported theme file — and copies its `theme` /// value into a real Theme. FIELD BY FIELD rather than by plain coercion, /// because a theme file deliberately imports nothing (it is data, not code) and /// zig will not coerce a whole anonymous struct into a named one; assigning one /// field at a time puts each value in a result location that knows the type, /// which is also what makes a missing field a compile error naming it. /// /// A FUNCTION and not a const for the same reason builtins.all is one: it is /// only ever a signature to whoever walks it, never a value in its own way. fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { comptime { @setEvalBranchQuota(400000); var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; for (@typeInfo(C).@"struct".decls, 0..) |d, i| { var t: Theme = undefined; for (@typeInfo(Theme).@"struct".fields) |f| @field(t, f.name) = @field(@field(C, d.name).theme, f.name); out[i] = t; } return out; } } /// The ring: ours, then every theme tools/gen_themes.zig exported out of the /// helix and zed sources in vendor/themes (build.zig runs it and hands the /// result over as a module). Adding one is dropping a file in there — there is /// no list here to append to, which is the whole point of folding the files. /// 228 of them: everything helix and zed ship, because "which of these is worth /// having" is the user's call and not the build's. That length is why ThemeSel /// exists — NextColor is a browse, not a way to arrive anywhere in particular. pub const themes = fold(curated) ++ fold(@import("generated_themes")); comptime { if (themes.len > std.math.maxInt(u16) + 1) @compileError("runtime config theme index no longer fits u16"); } // Two themes answering to one name is a bug: `Theme ` resolves by name // and would silently pick whichever came first, and ThemeSel would list the // loser as a row that does nothing. The generated half cannot collide with // ITSELF — one file per theme, all imported into one struct, so zig's own // redeclaration error already catches that — which leaves exactly the cross // pair to check here, three times 225 rather than 228 squared. This is the // check that makes helix's `acme.toml` vendored as `acme_helix.toml`. comptime { @setEvalBranchQuota(20000); const ours = fold(curated).len; for (themes[0..ours]) |c| for (themes[ours..]) |g| if (std.mem.eql(u8, c.name, g.name)) @compileError("theme name \"" ++ c.name ++ "\" is both ours and generated; rename the vendored source"); } /// The color roles attached to stable screen geometry. Document backgrounds, /// syntax, terminal ANSI palettes, and PDF tint colors deliberately are not /// here: those switch to `theme()` immediately while this small palette moves /// between themes over a handful of display frames. pub const ChromeTheme = struct { tag_bg: [3]u8, tag_fg: [3]u8, box: [3]u8, box_dim: [3]u8, lineno: [3]u8, scroll_track: [3]u8, scroll_thumb: [3]u8, pub fn fromTheme(th: *const Theme) ChromeTheme { return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, .box = th.box, .box_dim = th.box_dim, .lineno = th.lineno, .scroll_track = th.scroll_track, .scroll_thumb = th.scroll_thumb, }; } pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; const ChromeAnimation = animation.Transition(ChromeTheme); const initial_chrome = ChromeTheme.fromTheme(&themes[0]); // ---- the boundary types ---- pub const Color = union(enum) { default, index: u8, rgb: [3]u8 }; pub const FontRole = enum(u8) { body, tagline }; pub const FontSizeUnit = runtime_cfg.FontSizeUnit; pub const CellStyle = struct { fg: Color = .default, bg: Color = .default, bold: bool = false, dim: bool = false, italic: bool = false, blink: bool = false, reverse: bool = false, invisible: bool = false, strikethrough: bool = false, ul: enum { off, single, double, curly, dotted, dashed } = .off, font_role: FontRole = .body, }; /// One surface cell. `default = true` means "never painted this frame": the /// shell renders it as the terminal's default cell (vaxis clear semantics). // EFFECT_CODE_ASCII_DIFF_BEGIN pub const Cell = struct { text: [7]u8 = @splat(' '), len: u8 = 1, style: CellStyle = .{}, default: bool = true, pub fn grapheme(c: *const Cell) []const u8 { return c.text[0..c.len]; } /// Equality of what a shell can actually present. Bytes past `len` are /// scratch left by earlier graphemes and must never manufacture a panel /// diff; an unpainted default cell likewise has no visible style/text. pub fn visuallyEqual(a: *const Cell, b: *const Cell) bool { if (a.default or b.default) return a.default and b.default; return a.len == b.len and std.mem.eql(u8, a.grapheme(), b.grapheme()) and std.meta.eql(a.style, b.style); } /// The byte an ASCII transition may walk. Default cells are visibly /// spaces; painted cells opt in only when their complete grapheme is one /// printable byte. This keeps an intermediate frame valid UTF-8 and /// prevents a style-only or multi-byte change from churning its glyph. pub fn printableAscii(c: *const Cell) ?u8 { if (c.default) return ' '; if (c.len != 1) return null; const byte = c.text[0]; return if (byte >= ' ' and byte <= '~') byte else null; } }; /// The semantic character part of one old/new panel-cell diff. It lives in /// the core because every renderer must present the same byte at a given /// frame. Backends receive the already-composed Cell; none implements this /// walk or chooses its own punctuation/noise threshold. pub const AsciiDiff = struct { from: u8, to: u8, /// Long printable-byte walks complete in about the same time as the other /// panel effects. Extra distance is crossed by eased character skips. pub const max_movement_frames = panel_animation.ascii_max_movement_frames; pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { const from = old.printableAscii() orelse return null; const to = new.printableAscii() orelse return null; if (from == to) return null; return .{ .from = from, .to = to }; } pub fn distance(diff: AsciiDiff) u8 { return if (diff.from < diff.to) diff.to - diff.from else diff.from - diff.to; } pub fn movementFrames(diff: AsciiDiff) u16 { return @min(@as(u16, diff.distance()), max_movement_frames); } pub fn frameCount(diff: AsciiDiff) u16 { return diff.movementFrames() + 1; } /// Move through the u8 range with integer ease-in-out over exactly /// `movementFrames` samples. A byte creeps at both ends and crosses the /// middle of its distance in a few large skips, inside the same frame /// count the old constant one-byte-per-frame walk took. Keeping this /// integer-only makes every backend receive the same character. pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { const movements: u32 = diff.movementFrames(); const at: u32 = @min(@as(u32, frame), movements); const delta: u8 = @intCast(easedDistance(diff.distance(), movements, at)); return if (diff.from < diff.to) diff.from + delta else diff.from - delta; } /// `distance * smootherstep(at / movements)`, rounded, without touching /// floating point. Endpoints are exact — zero at frame zero, the whole /// distance at the last movement — and the curve is monotonic, so a byte /// never walks backwards between samples. fn easedDistance(span: u8, movements: u32, at: u32) u32 { if (movements == 0 or at >= movements) return span; const n: u64 = at; const d: u64 = movements; // Quintic smootherstep as one exact fraction: n^3 (10 d^2 + 6 n^2 - // 15 d n) over d^5. The positive terms are summed first because // 10 d^2 + 6 n^2 >= 15 d n for every n <= d: unsigned arithmetic must // never see the intermediate go below zero. const shape = n * n * n * (10 * d * d + 6 * n * n - 15 * d * n); const denominator = d * d * d * d * d; return @intCast((@as(u64, span) * shape + denominator / 2) / denominator); } pub fn complete(diff: AsciiDiff, frame: u16) bool { return diff.byteAt(frame) == diff.to; } }; /// One core-owned classification per cell in the frozen old/new grid. A /// visual-only diff still matters to dissolve, but PanelAscii only walks the /// `.ascii` case. That distinction fixes the old effect's habit of replacing /// unchanged glyphs merely because their colour or other style changed. pub const PanelCellDiff = union(enum) { unchanged, visual, ascii: AsciiDiff, pub fn between(old: *const Cell, new: *const Cell) PanelCellDiff { if (old.visuallyEqual(new)) return .unchanged; if (AsciiDiff.between(old, new)) |diff| return .{ .ascii = diff }; return .visual; } pub fn changed(diff: PanelCellDiff) bool { return diff != .unchanged; } }; // EFFECT_CODE_ASCII_DIFF_END test "cell visual equality ignores dead grapheme tail bytes" { var a: Cell = .{ .default = false }; var b = a; a.text[0] = 'x'; b.text[0] = 'x'; a.text[4] = 'a'; b.text[4] = 'b'; try std.testing.expect(a.visuallyEqual(&b)); b.style.bold = true; try std.testing.expect(!a.visuallyEqual(&b)); a.default = true; b.default = true; try std.testing.expect(a.visuallyEqual(&b)); } test "ASCII cell diffs ease long byte walks in both directions" { var low: Cell = .{ .default = false }; low.text[0] = 'A'; var high: Cell = .{ .default = false }; high.text[0] = 'F'; // Ease-in-out inside exactly the five movement frames the old constant // walk used: the first sample holds, the middle crosses two values at a // time, and the endpoint is exact. const rising = AsciiDiff.between(&low, &high).?; try std.testing.expectEqual(@as(u16, 6), rising.frameCount()); try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(1)); try std.testing.expectEqual(@as(u8, 'C'), rising.byteAt(2)); try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(4)); try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(500)); const falling = AsciiDiff.between(&high, &low).?; try std.testing.expectEqual(@as(u8, 'D'), falling.byteAt(2)); try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(4)); low.text[0] = ' '; high.text[0] = '~'; const long_rising = AsciiDiff.between(&low, &high).?; try std.testing.expectEqual(@as(u16, 13), long_rising.frameCount()); try std.testing.expectEqual(@as(u8, ' '), long_rising.byteAt(0)); try std.testing.expectEqual(@as(u8, '#'), long_rising.byteAt(2)); try std.testing.expectEqual(@as(u8, 'O'), long_rising.byteAt(6)); try std.testing.expectEqual(@as(u8, '{'), long_rising.byteAt(10)); try std.testing.expectEqual(@as(u8, '~'), long_rising.byteAt(12)); const long_falling = AsciiDiff.between(&high, &low).?; try std.testing.expectEqual(@as(u8, 'O'), long_falling.byteAt(6)); try std.testing.expectEqual(@as(u8, ' '), long_falling.byteAt(12)); // The shape itself is the contract: never backwards, and the widest step // is in the middle of the walk rather than at either end. var previous = long_rising.byteAt(0); var first_step: u8 = 0; var middle_step: u8 = 0; var frame: u16 = 1; while (frame <= long_rising.movementFrames()) : (frame += 1) { const byte = long_rising.byteAt(frame); try std.testing.expect(byte >= previous); const step = byte - previous; if (frame == 1) first_step = step; if (frame == 6) middle_step = step; previous = byte; } try std.testing.expect(middle_step > first_step); try std.testing.expect(middle_step > long_rising.byteAt(12) - long_rising.byteAt(11)); } test "ASCII diff classification skips stable and non-ASCII glyphs" { var old: Cell = .{ .default = false }; old.text[0] = 'x'; var style_only = old; style_only.style.bold = true; try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &style_only)); var changed = old; changed.text[0] = 'z'; try std.testing.expectEqual(AsciiDiff{ .from = 'x', .to = 'z' }, PanelCellDiff.between(&old, &changed).ascii); var unicode = old; unicode.text[0..2].* = .{ 0xc3, 0xa9 }; unicode.len = 2; try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &unicode)); } /// One generation of a pixel attachment. `serial` identifies the pane for its /// whole lifetime; `revision` identifies pixels rendered later by that same /// pane (for example, a different PDF page or zoom level). Backends must use /// both: pane slots are reused, while a live pane may replace its pixels. pub const ImageCacheKey = if (pdf_enabled) struct { serial: u32, page: u32, revision: u32, pub fn eql(a: @This(), b: @This()) bool { return a.serial == b.serial and a.page == b.page and a.revision == b.revision; } } else struct { serial: u32, pub fn eql(a: @This(), b: @This()) bool { return a.serial == b.serial; } }; const PdfFitMode = pdf_pane.FitMode; const PdfTintMode = pdf_pane.TintMode; /// Dynamic placement exists only for native PDF pages. Static image panes need /// only their pane identity, so feature-off builds carry a zero-bit payload. pub const NativePlacement = if (pdf_enabled) struct { revision: u32 = 0, page: u32 = 0, fit: image.NativeFit = .contain, pan_x: u16 = 0, pan_y: u16 = 0, /// Continuous-document pages supply their already-clipped geometry. /// Static images and legacy callers leave this null and derive fit/pan. geometry: ?image.NativeGeometry = null, /// Subpixel vertical displacement retained from proportional SDL wheel dy. pixel_offset_y: f32 = 0, } else struct {}; /// A pixel image riding the surface: the shell transmits/places it over the /// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture). /// This is also the backend-neutral transport for rasterized PDF pages. pub const ImagePlace = struct { pane: u8, /// Pane slots are reused. This identity makes a cached GPU texture or /// kitty image unambiguously belong to the pane which supplied the bytes. serial: u32, native: NativePlacement = .{}, x: u16, y: u16, w: u16, h: u16, rgba: []const u8, iw: usize, ih: usize, pub fn cacheKey(place: ImagePlace) ImageCacheKey { if (comptime pdf_enabled) return .{ .serial = place.serial, .page = place.native.page, .revision = place.native.revision, }; return .{ .serial = place.serial }; } }; test "pixel attachment cache key follows both pane lifetime and rendered revision" { if (comptime pdf_enabled) { const first = ImageCacheKey{ .serial = 41, .page = 7, .revision = 3 }; try std.testing.expect(first.eql(.{ .serial = 41, .page = 7, .revision = 3 })); try std.testing.expect(!first.eql(.{ .serial = 41, .page = 8, .revision = 3 })); try std.testing.expect(!first.eql(.{ .serial = 41, .page = 7, .revision = 4 })); try std.testing.expect(!first.eql(.{ .serial = 42, .page = 7, .revision = 3 })); } else { const first = ImageCacheKey{ .serial = 41 }; try std.testing.expect(first.eql(.{ .serial = 41 })); try std.testing.expect(!first.eql(.{ .serial = 42 })); } try std.testing.expectEqual(pdf_enabled, @hasField(ImageCacheKey, "revision")); try std.testing.expectEqual(pdf_enabled, @hasField(NativePlacement, "fit")); try std.testing.expectEqual(pdf_enabled, @hasField(CellPixels, "w")); if (comptime !pdf_enabled) { try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(pdf_pane.PointerDrag)); } } test "pixel attachment fit and pan do not invalidate cached pixels" { if (comptime !pdf_enabled) return; var place = ImagePlace{ .pane = 0, .serial = 41, .native = .{ .revision = 3 }, .x = 0, .y = 0, .w = 1, .h = 1, .rgba = &.{ 0, 0, 0, 255 }, .iw = 1, .ih = 1, }; const key = place.cacheKey(); place.native.fit = .width; place.native.pan_x = 1234; place.native.pan_y = 65535; try std.testing.expect(key.eql(place.cacheKey())); } /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { cols: u16 = 0, rows: u16 = 0, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, /// Pixel attachments are the exact visible set. PDFs can legally contain /// arbitrarily short pages, so no fixed page-count array can represent a /// viewport without occasionally dropping an intersecting page. images: []?ImagePlace = &.{}, nimages: usize = 0, /// Active pane transitions, keyed by the stable pane serial carried in /// each record. GUI shells evaluate these in shaders; the TTY remaps this /// same frame's cells through its grid compositor. panel_tracks: [MAX_PANES * 2]panel_animation.Track = undefined, npanel_tracks: usize = 0, /// Frozen canonical cells from before the current content/lifecycle /// transition, plus the core's semantic old/new classifications. Both are /// core-owned and remain stable until every associated track has finished. previous_cells: []const Cell = &.{}, cell_diffs: []const PanelCellDiff = &.{}, pub fn panelTracks(s: *const Surface) []const panel_animation.Track { return s.panel_tracks[0..s.npanel_tracks]; } pub fn hasPanelDiff(s: *const Surface) bool { return s.previous_cells.len == s.cells.len and s.cell_diffs.len == s.cells.len; } pub fn panelCellChanged(s: *const Surface, x: u16, y: u16) bool { if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return false; return s.cell_diffs[@as(usize, y) * s.cols + x].changed(); } pub fn panelCellDiff(s: *const Surface, x: u16, y: u16) PanelCellDiff { if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return .unchanged; return s.cell_diffs[@as(usize, y) * s.cols + x]; } pub fn at(s: *Surface, x: u16, y: u16) *Cell { std.debug.assert(x < s.cols and y < s.rows); return &s.cells[@as(usize, y) * s.cols + x]; } pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void { const c = s.at(x, y); std.debug.assert(text.len <= c.text.len); @memcpy(c.text[0..text.len], text); c.len = @intCast(text.len); c.style = style; c.default = false; } /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the /// column after the last written cell. Wide glyphs take two cells. /// /// The text is NOT trusted to be valid UTF-8 — a file pane holds whatever /// bytes are on disk (latin-1 source, an ELF opened by mistake), a path can /// be any bytes at all, and a search row splices both. std's unchecked /// iterator panics on a bad start byte, so decode by hand and paint one /// U+FFFD per undecodable byte (what a terminal does). fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; const end = x + w; var i: usize = 0; while (i < text.len) { if (col >= end) break; // n == 0: not a start byte at all. A short tail or a bad // continuation decodes to null the same way — one U+FFFD, one byte. const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; const decoded: ?u21 = if (n > 0 and i + n <= text.len) (std.unicode.utf8Decode(text[i .. i + n]) catch null) else null; var cp_slice: []const u8 = "\u{FFFD}"; var consumed: usize = 1; if (decoded != null) { // A surface cell is a grapheme, not a codepoint. Keeping the // complete cluster makes combining marks visible and keeps ZWJ, // modifier, flag and Indic sequences in the same screen cell // that cursor/edit math treats as one unit. var git = uucode.grapheme.utf8Iterator(text[i..]); if (git.nextGrapheme()) |g| { const candidate = text[i .. i + g.end]; if (std.unicode.utf8ValidateSlice(candidate)) { cp_slice = candidate; consumed = candidate.len; } else { cp_slice = text[i .. i + n]; consumed = n; } } } i += consumed; var cp = decoded orelse 0xFFFD; if (cp == '\r') continue; // A Surface cell is already positioned, not a terminal byte // stream. Expand tabs here so every Surface consumer — GUI, tty, // web, and macOS — sees the same configured run of blank cells // instead of asking its font for a control-character glyph. if (cp == '\t') { const spaces = @min(config.tab_width, end - col); s.fill(col, y, spaces, 1, style); col += spaces; continue; } // No other C0/C1 control or DEL reaches a font fallback either. if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) { cp = 0xFFFD; cp_slice = "\u{FFFD}"; } const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1)) 1 else @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); // a DOUBLE-width glyph with one column left is not drawn at all. // Writing it puts one cell in the surface and two on the glass, and // when that column is the screen's last the terminal wraps the tail // onto the next row — where our own model says "space", so the diff // render never repaints it and the smear outlives the frame. A // blank at the edge is what every terminal does with the same // problem. Reachable from any byte cut through wide text: hscroll's // and soft wrap's both. if (width == 2 and col + 1 >= end) break; // The cross-host Cell ABI has seven payload bytes. Preserve a valid // codepoint prefix when a modern emoji cluster is longer; its full // width and edit boundary still come from the complete cluster. var shown = cp_slice; if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { const cap = @typeInfo(@FieldType(Cell, "text")).array.len; var prefix: usize = 0; while (prefix < shown.len) { const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break; if (prefix + cp_len > cap) break; prefix += cp_len; } shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}"; } s.set(col, y, shown, style); if (width == 2 and col + 1 < end) { // spacer: empty cell under the wide glyph's tail s.set(col + 1, y, "", style); } col += width; } return col; } pub fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void { var yy = y; while (yy < y + h) : (yy += 1) { var xx = x; while (xx < x + w) : (xx += 1) s.set(xx, yy, " ", style); } } fn clearRect(s: *Surface, x: u16, y: u16, w: u16, h: u16) void { var yy = y; while (yy < y + h) : (yy += 1) { var xx = x; while (xx < x + w) : (xx += 1) s.at(xx, yy).* = .{}; } } /// swap ONLY the glyph, keeping the cell's colors — the resize-handle hint fn overlayDash(s: *Surface, x: u16, y: u16, glyph: []const u8) void { const c = s.at(x, y); @memcpy(c.text[0..glyph.len], glyph); c.len = @intCast(glyph.len); c.default = false; } }; test "surface print expands configured tabs and normalizes other controls" { const tab_cells: usize = @as(usize, config.tab_width) * 2; const replacement_cells = 5; const cell_count = tab_cells + replacement_cells + 1; var cells: [cell_count]Cell = @splat(.{}); var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells }; const end = surface.print(0, 0, cells.len, "\t\t\x00\x0b\x0c\r\x7f\xc2\x85A", .{}); try std.testing.expectEqual(@as(u16, cells.len), end); for (cells[0..tab_cells]) |cell| try std.testing.expectEqualStrings(" ", cell.grapheme()); for (cells[tab_cells .. tab_cells + replacement_cells]) |cell| try std.testing.expectEqualStrings("\u{FFFD}", cell.grapheme()); try std.testing.expectEqualStrings("A", cells[cell_count - 1].grapheme()); } test "surface print keeps combining and wide graphemes in their display cells" { var cells: [8]Cell = @splat(.{}); var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells }; const end = surface.print(0, 0, cells.len, "e\u{301}界👩🏽\u{200d}🚀1\u{fe0f}\u{20e3}A", .{}); try std.testing.expectEqual(@as(u16, 8), end); try std.testing.expectEqualStrings("e\u{301}", cells[0].grapheme()); try std.testing.expectEqualStrings("界", cells[1].grapheme()); try std.testing.expectEqualStrings("", cells[2].grapheme()); try std.testing.expectEqualStrings("👩", cells[3].grapheme()); try std.testing.expectEqualStrings("", cells[4].grapheme()); try std.testing.expectEqualStrings("1\u{fe0f}\u{20e3}", cells[5].grapheme()); try std.testing.expectEqualStrings("", cells[6].grapheme()); try std.testing.expectEqualStrings("A", cells[7].grapheme()); } test "insert and normal modes edit complete Unicode graphemes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.hxOpenFileContent("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z"); pane.mode = .insert; pane.cur_col = 22; // on z, after the CJK grapheme p.insertKey(pane, .{ .cp = Key.left }); try std.testing.expectEqual(@as(i32, 19), pane.cur_col); p.insertKey(pane, .{ .cp = Key.left }); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); p.insertKey(pane, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 19), pane.cur_col); p.insertKey(pane, .{ .cp = Key.backspace }); try std.testing.expectEqualStrings("ae\u{301}界z", pane.file.?.content); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); pane.mode = .normal; pane.cur_col = 1; p.normalDelete(pane, false); try std.testing.expectEqualStrings("a界z", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("aλz", pane.file.?.content); file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 3; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("λλ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 2), pane.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 0; pane.vsel = .{ .active = true, .row = 0, .col = 3, .explicit = true }; p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("λλ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 0), pane.cur_col); try std.testing.expectEqual(@as(i32, 2), pane.vsel.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.hxOpenFileContent("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 while preserving virtual columns" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.hxOpenFileContent("\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); // Display-column conversion must not turn a click in the blank space // after EOL into a click on the final byte. Besides moving the modal // cursor, that would make a no-drag Look expand the last word instead of // remaining inert over blank space. const line = modal.lineSlice(f.content, 0); const virtual: u16 = 3; const blank_x = text_x + @as(u16, @intCast(file_pane.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 + @as(usize, virtual))), pane.cur_col); _ = frame.reset(.retain_capacity); const virtual_surface = try p.render(frame.allocator()); try std.testing.expectEqual(blank_x, 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 + file_pane.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.hxOpenFileContent("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(p.layoutSplitColumn(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 = mix(p.theme().bg orelse p.theme().tag_bg, 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); // A real leave cannot be represented as an out-of-range motion: motion // coordinates are deliberately clamped for drags. It also clears the // ordinary resize-handle hint, not only this Look-specific preview. 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())); // Blank space after EOL has a pointer cell but no Look operand. Ageing it // to completion must turn the animation clock back off without drawing a // misleading one-cell preview. 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. const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y; p.look_hover_preview = .{ .col = rect.x + config.GUTTER, .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 + config.GUTTER, 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.hxOpenFileContent("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.hxOpenFileContent("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.panel_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); } pub const Mouse = struct { // 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 }; pub const Kind = enum { press, release, motion, drag }; button: Button, kind: Kind, col: u16, row: u16, /// Ctrl held during the click. Only the left press reads it (ctrl-click = /// goto-definition, the one chord every editor with an LSP has); every /// other button ignores it, because acme's button semantics are already /// the vocabulary here and modifiers are not part of it. ctrl: bool = false, }; pub const Key = struct { cp: u21, // codepoint or one of the specials below text: []const u8 = "", ctrl: bool = false, alt: bool = false, /// Only consulted for keys whose codepoint does NOT already carry the /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `hit()` /// ignores this field unless a binding explicitly asks for it. 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; }; /// Does this press match a binding? Every keymap test in the core goes through /// here, so the modifier rules are written once instead of once per modifier /// (the three is/isC/isA predicates this replaces each spelled out the same /// comparison with a different pair of negations). /// /// A binding is a LIST: most have two spellings that must reach the same arm — /// `h` and Left, `Ctrl-f` and PageDown — and the `or` chain that used to do /// that at every call site is now one loop here. 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; // shift is carried in the codepoint for anything printable (`A` is // `A`, not shift-`a`), so it is consulted ONLY when a binding asks for // it — otherwise every letter binding would newly demand shift be up if (c.shift and !key.shift) continue; return true; } return false; } /// `Pane.sel` is indexed by @intFromEnum(button), so this is the SELECT /// button's slot: where a sweep lands and where the left half of every acme /// chord reads its selection from. Named rather than the bare 0 it used to be /// so that config.select_button is genuinely the only place the choice is /// made. const sel_slot = @intFromEnum(config.select_button); /// The same test for a MODAL PREFIX (`g`, `z`, `m`, `mi`, `]`...). config /// spells those as bare codepoints rather than Chords because pardes stores /// the codepoint itself in pane.pending until the next key completes the /// sequence — so this is the one place the two shapes meet, and it is the same /// comparison the stored byte gets a few lines later. fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } fn roleBindingName(comptime role: normal_input.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), }; } /// The single physical-key -> BODY-NORMAL vocabulary seam. A key may carry /// several roles (`h` is both move-left and `gh`'s line-start); the pure /// normal_input parser chooses among them from its explicit prefix state. /// Both text panes and PDF panes call this exact function before adapting the /// resulting semantic Action. fn normalInput(key: Key) normal_input.Input { var out: normal_input.Input = .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, }; inline for (std.enums.values(normal_input.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, /// Physical pixels in one grid cell, present only when native PDF /// placement is compiled in. Defaults keep headless/core callers /// useful and give terminals which cannot report pixels the /// conventional 1:2 cell aspect. cell_pixels: CellPixels = .{}, }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, /// a language query the shell ran on a worker has finished. `rows` is /// `+Search`-format text (see lsp.zig) and is borrowed for this call only, /// exactly like `output` bytes. An id the core no longer recognises is a /// stale answer (the pane was closed, or a newer query superseded it) and /// is dropped. lsp_resp: struct { id: u32, rows: []const u8 }, /// A selection-pipe worker finished. Every output is borrowed for this /// update only; success is atomic, so a failed/nonzero invocation carries /// no usable outputs and changes nothing. pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8 }, /// a file the shell was asked to watch changed on disk; `bytes` are the /// exact snapshot the host hashed, borrowed for this call like `output`. /// Text panes adopt a copy; PDF panes reopen the path so MuPDF owns its /// random-access document. A shell with no filesystem (the browser) or no /// watcher simply never sends one — nothing in the core waits for it. file_changed: struct { pane: u8, bytes: []const u8 }, /// Text from the SYSTEM clipboard, borrowed for this call. Two ways in, /// one handler (applyPaste): SOLICITED, the answer to a `read_clipboard` /// the core emitted for `SPC p` / `SPC P` / `SPC R`, which decides where /// it lands; and UNSOLICITED — an outer terminal's bracketed paste, a /// Cmd-V, the browser's paste event — which means `SPC p`, paste after. /// Neither touches the default register: that is `y`'s alone (helix). paste: []const u8, /// One builtin command line, handed to the shell by a pardes launched /// INSIDE this one (see nested.zig) — `Look /abs/path` and nothing else /// today. Borrowed for this call exactly like `output` bytes. It comes in /// as an EVENT rather than a direct executeBuiltinLine call so it gets the /// trailing sync and the ordinary effect drain: `Look` on a directory /// emits a `.spawn` the shell has to perform. 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, /// ONE FILESYSTEM REQUEST from a process that opened a file under the /// acme-style control mount (src/acmefs.zig, served by src/fuse.zig). /// `data` is borrowed for this call exactly like `output` bytes, which is /// why this — like them — never goes through `postEvent`. The answer /// leaves as an `Effect.fs_reply` in the same update, so the transport /// that asked is the one that writes it back: no thread, no waiting and /// no filesystem knowledge anywhere in here. fs_req: acmefs.Req, }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const Effect = union(enum) { spawn: struct { pane: u8, cwd: Buf(256) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, 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 }, /// Write this pane's text to a path WITHOUT converting the pane — a /// terminal's scrollback, a results buffer's rows, a file copied elsewhere. /// The path travels HERE, bounded exactly like a spawn's cwd, so two saves /// armed in one batch cannot cross; the BYTES are read off the pane when /// this is performed, the way save_file reads a file pane. `serial` is the /// pane it was armed for: a slot freed and reused before the drain writes /// nothing rather than another pane's text to this path. save_text: struct { pane: u8, serial: u32, path: Buf(256) }, /// a serialized state dump is ready in core.dump_out; write it to the /// path dump.outPath resolves (acme-style: another instance loads it /// with -l, or the Restore builtin loads it into this one) write_dump, /// mirror the yank register OUT to the system clipboard; the shell reads /// it off the core (OSC 52 out, SDL_SetClipboardText, NSPasteboard). /// Emitted ONLY by the explicit clipboard commands — see setClipboard. set_clipboard, /// ...and the other direction: ask the shell to READ the system clipboard. /// The answer comes back as an ordinary `Event.paste`, which the core /// routes to whichever of `SPC p` / `SPC P` / `SPC R` asked for it /// (Pardes.clip_pending). No payload: the bytes travel in the event. read_clipboard, /// answer a language query OFF the event loop and post the rows back as an /// `lsp_resp` Event. The shell reads the file's path and content off the /// core (like save_file) and must SNAPSHOT them before the worker starts — /// the core keeps editing while this is in flight. 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 }, /// start (`on`) or stop watching this pane's file on disk. Starting, the /// shell reads the path off the core exactly like save_file does; stopping /// carries nothing, because by the time an `off` is drained the pane is /// already freed — the shell remembers what it watches per pane id. /// Real text files and PDFs ask for this; an output buffer has no file /// behind it. watch: struct { pane: u8, on: bool }, /// Load/unload the one runtime theme file watch. The path lives in the /// core's fixed request buffer; carrying only its generation keeps this /// already-large effect ring compact. 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 }, /// The answer to an `Event.fs_req`. The bytes are NOT in here: `payload` /// says where they live (a staging buffer in the core, or a range of a /// pane's live text) and `fsPayload` resolves it during the drain, so a /// megabyte read costs one `writev` and no copy. `.again` means the core /// has nothing yet and the transport must ask again later — acme's /// blocking `event` read, with the waiting left where the kernel's /// request already is. fs_reply: acmefs.Reply, 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; } }; } }; pub const Mode = enum { normal, insert, tty }; /// An owned editable buffer and the absolute surface row of its first line. /// Files use row zero; terminal overlays may begin anywhere in scrollback. pub const EditText = struct { text: []u8, row0: i32 }; /// One mouse selection (block-shaped), per button. c/r are text-area relative; /// r counts from the tag row (body starts at BOX_H). pub const Sel = struct { state: enum { none, dragging, done } = .none, c0: i32 = 0, c1: i32 = 0, r0: i32 = 0, r1: i32 = 0, }; const LookHoverWait = struct { col: u16, row: u16, pane: usize, serial: u32, frames: u16 = 0, }; const LookHoverPreview = struct { col: u16, row: u16, pane: usize, serial: u32, /// Null when the operand is the pane's modal selection. The renderer uses /// that marker to paint the live modal range subtly even while tag/search /// editing would normally hide it. A kept mouse selection carries its /// exact screen-space range; an ordinary word carries the click expansion. sel: ?Sel, /// A wrapped file word is one logical source span, not one rectangular /// screen selection. Keeping it in source coordinates lets the renderer /// paint every visible continuation while Look receives the exact same /// bytes, including a path which crosses a soft-wrap boundary. file_word: ?FileWordSpan = 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: pdf_pane.WordProbe, fn deinit(preview: *@This(), gpa: std.mem.Allocator) void { preview.probe.deinit(gpa); preview.* = undefined; } } else void; /// A modal line selection (helix `x`): whole rows [r0, r1], absolute. pub const LineSel = struct { active: bool = false, r0: i32 = 0, r1: i32 = 0, }; /// A modal char-range selection: the anchor lives here, the head is the pane /// cursor. Since the helix motion model landed, EVERY motion leaves one of /// these — `explicit` separates user-intent selections (v / x / X / terminal /// n/N / file-search n/N) from bare motion residue: the acme Enter/Tab chords /// only act on explicit ones. Mutually exclusive with LineSel. pub const CharSel = struct { active: bool = false, row: i32 = 0, col: i32 = 0, explicit: bool = false, }; /// One position in the n/N walk: a look-able span on one row of one pane, /// inclusive of both columns. Also what the walk REMEMBERS having stood on /// (Pane.look_at) — see lookStand for why the cursor alone cannot say. pub const LookSpot = struct { row: i32, col0: i32, col1: i32, }; /// ponytail: at most this many cursors at once. helix's `Selection.ranges` is /// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo /// snapshots memcpy it) and costs nothing at one cursor. The ceiling only /// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are /// simply not created — raise the bound if that ever matters. pub const MAX_SELS = 64; /// One selection range in PANE coordinates: the block-cursor cell and the /// anchor cell. Deliberately the same pair `cur_row`/`cur_col` + `vsel` /// already are, so a range moves in and out of the primary slot without a /// conversion. pub const SelRange = struct { row: i32, col: i32, arow: i32, acol: i32, /// this range's own j/k goal column (helix Range::old_visual_position); /// the PRIMARY's copy is Pane.sticky_col sticky: i32 = -1, }; const PdfSlot = if (pdf_enabled) ?pdf_pane.State else void; /// The emulator's raw-byte dump/replay ring, and NOTHING where there is no pty /// to read bytes from — same shape as `PdfSlot`, for a much harder reason. It /// is a MEGABYTE inline in every Pane: on the P4 the whole heap is 384 KiB, so /// carrying it would make `gpa.create(Pane)` fail before anything could ask /// for a grid, and `Pardes.init` — which creates a pane unconditionally — /// could not return. const ReplaySlot = if (terminal_panes) [TTY_REPLAY_CAP]u8 else void; /// The staging buffer for the query replies ghostty computes. Its only writer /// is term_pane's `ptyReport` callback, which does not exist without an /// emulator, so `reply_len` there is permanently 0 and `sync` never reads it. const ReplySlot = if (terminal_panes) [256]u8 else void; fn hasPdf(pane: *const Pane) bool { return if (comptime pdf_enabled) pane.pdf != null else false; } fn hasPdfSelection(pane: *const Pane) bool { return if (comptime pdf_enabled) if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false else false; } const Prompt = union(enum) { none, search: u16, pipe: u16, /// Save on a scratch buffer or a terminal: the tail is a path to write to. save: u16, }; pub const Pane = struct { /// A pane's working directory. `.inherited` is a live `*Pane` link kept /// valid by deferred teardown (see PaneAllocator) + reapPanes' fixup. pub const Cwd = union(enum) { none, inherited: *Pane, owned: []const u8 }; vt: term_pane.VtSlot, stream: term_pane.StreamSlot, /// The same allocator Pardes holds. A pane already owns heap (its content, /// its emulator, its undo stacks) and Pardes frees all of it; this is here /// so the pane methods that need the file's LINE INDEX — scrollBy and /// ensureCursorVisible — can build it. The alternative was /// threading an allocator through ensureCursorVisible's 33 call sites. gpa: std.mem.Allocator, /// WHICH pane this is, for anything that outlives the pane: slots are /// REUSED (freeSlot hands back the lowest free one), so a remembered id /// alone can silently come to mean an unrelated pane. Handed out by /// Pardes.next_serial and never reused. The one reader is the jump stack. serial: u32 = 0, mode: Mode = .normal, vweight: f32 = 1, cols: u16, rows: u16, greet: bool = false, pending_command: term_pane.PendingCommand = .{}, file: ?file_pane.State = null, image: ?image_pane.State = null, pdf: PdfSlot = if (pdf_enabled) null else {}, msel: LineSel = .{}, vsel: CharSel = .{}, /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary /// index; pardes keeps the PRIMARY exactly where it has always been — /// cur_row/cur_col + vsel — and the other ranges here, document-ordered /// and disjoint (helix's Selection::normalize). That split is the whole /// design: every motion, operator, renderer and mouse path in this file /// still reads one selection, so with `nsel == 0` not a byte of behaviour /// moves, and the 800 differential cases and 66 snapshots keep proving it. /// The extra ranges are driven by replaying the single-selection key /// handler once per range (see replaySels). sels: [MAX_SELS - 1]SelRange = undefined, nsel: u8 = 0, /// helix select/extend mode (`v`): motions extend the selection from its /// fixed anchor instead of replacing it. Reported as mode "select"; /// pane.mode stays .normal (insert/tty transitions drop it). select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. sticky_col: i32 = -1, /// an `a` append session's original block-cursor cell: Esc backs the /// cursor up one grapheme and rebuilds the appended-over selection from /// here (helix doc.restore_cursor). Null outside `a` sessions. append_at: ?struct { row: i32, col: i32 } = null, /// Compact storage for normal_input.State's match sub-prefix. pending2: u21 = 0, /// Compact storage for normal_input.State's `mr` held char. pending_ch: u21 = 0, /// Compact storage for normal_input.State's count (0 = none). count: u32 = 0, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, find_ch: u21 = 0, /// Compact storage for normal_input.State's typed prefix. pending: u21 = 0, /// Tag-tail input state. The tag text is presentation; this tag carries /// which operation owns it and the tail offset restored on submit/cancel. prompt: Prompt = .none, search_pane: ?usize = null, search_row: ?usize = null, /// Where n/N last stood in this pane, or null when the walk has not been /// here. Distinct from `search_row`, which points into the RESULTS BUFFER /// a search armed on this pane; this one is a place in the pane's own /// text, and n/N step it in every kind of pane. look_at: ?LookSpot = null, /// The selection an `s`/`S` input was armed on, as gap offsets over the /// motion surface. Every keystroke re-derives the preview FROM here rather /// than from the previous preview — which is what helix's regex_prompt /// does (it reverts to its snapshot before each update), what makes typing /// a pattern one character at a time land on the same answer as pasting it /// whole, and what makes Esc a plain restore with nothing else to undo. /// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one /// place the prompt is cleared, clears it too. sel_snap: [MAX_SELS]modal.HxRange = undefined, nsel_snap: u8 = 0, sel_snap_pri: u8 = 0, /// ...including whether it was a user-intent selection: a preview IS one /// (you picked those matches), but restoring must not silently promote /// motion residue into something the acme chords will act on sel_snap_expl: bool = false, /// the editable tag tail: a bounded one-line command buffer. Input that /// does not fit is refused atomically. tag_tail: [TAG_TAIL_CAP]u8 = undefined, tag_tail_len: usize = 0, tag_init: bool = false, tag_edit: bool = false, tag_sel: bool = false, /// the body mode a tag edit hijacked (tags are always insert); terminals /// restore it on exit so clicking the tag never changes the pane's mode tag_mode: Mode = .normal, /// THE tag coordinate space: UTF-8 byte offsets into the WHOLE rendered /// tag, prefix ++ tail (tagText), always on grapheme boundaries. Motions /// and edits use these offsets; rendering and pointer input convert at the /// screen boundary. The prefix is live chrome, so it is selectable, /// yankable and executable but READ-ONLY: every edit op measures from /// `edit0` (= tagPrefix().len, the first editable byte) and does nothing /// left of it. tag_col: u16 = 0, tag_anchor: u16 = 0, ed_undo: [term_pane.history_max]term_pane.Snapshot = undefined, ed_undo_len: usize = 0, ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined, ed_redo_len: usize = 0, /// Working directory: shell-reported bytes (.owned, in cwd_buf), a live /// link to the pane it was opened from (.inherited), or unknown (.none). /// The inherited pointer is kept valid by deferred pane teardown + fixup. cwd: Cwd = .none, cwd_buf: [CWD_BUF_CAP]u8 = undefined, /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, /// or the terminal's shell rows with its edit buffer standing in). Tracks /// the shell cursor until pinned by a click or a key. cur_pinned: bool = false, cur_row: i32 = 0, cur_col: i32 = 0, /// horizontal scroll, file panes only (terminals wrap at pty width, they /// never have wider lines): content columns hidden left of the gutter. /// No scrollbar — the wheel and cursor movement (with scrolloff) drive it, /// the goal is just being able to read long lines. Byte columns, like the /// rest of the file-pane code. hscroll: i32 = 0, /// THE WRAP MAP, and the whole of what soft line breaks are: for every body /// row of the LAST frame, the document line it showed and the byte column /// of that line the row started at. `wrap_n == 0` says the body was NOT /// wrapped, i.e. the rows are the lines from `scroll()` down one each — /// which is exactly what wrapAt/wrapRow below fall back to, so with the /// toggle off not one reader computes anything it did not compute before. /// /// INVALIDATION, the part that rots if nobody says it out loud: written in /// EXACTLY ONE PLACE, file_pane.bodyText, on every build of a file pane's /// body. So it is at worst one frame old — which is what a mouse click /// wants (you click the character you can SEE), and it is fresh for the /// render passes, every one of which runs after bodyText inside the same /// renderPane call. Nothing else may write it; a second writer is a second /// truth, and the first click on a stale row is how you find out. /// /// ponytail: a fixed `WRAP_ROWS` rows (256; 128 on the board). A pane /// taller than that does not wrap at all — bodyText leaves wrap_n at 0 and /// clips the way it always did — rather than half-recording a mapping /// every site here would then have to distrust. `wrapWidth` derives that /// refusal from `wrap_line.len` itself, so the bound follows the array. /// Grow the arrays the day a taller window turns up. wrap_line: [WRAP_ROWS]i32 = undefined, wrap_col: [WRAP_ROWS]i32 = undefined, wrap_n: u16 = 0, sel: [3]Sel = @splat(.{}), /// terminals only: the typed-text buffer standing in for shell rows ovl: ?term_pane.EditBuffer = null, /// every raw pty byte, in order — a bounded dump/replay ring. Once full, /// new output evicts the oldest bytes while the live terminal still sees /// every byte. A megabyte, inline: see `ReplaySlot`. tty_stream: ReplaySlot = if (terminal_panes) undefined else {}, tty_stream_head: usize = 0, tty_stream_len: usize = 0, /// Terminal-only, pane-local presentation mode. Ghostty remains the owner /// of the unmodified VT palette and dynamic OSC colours; the renderer /// projects them through the active Pardes theme when this is set. tty_filter: bool = false, /// query replies ghostty computed (DSR, DA, kitty); the stream handler has /// no path to the effect queue, so they land here and sync() drains them /// into write effects. Bounded: replies are tiny escape sequences. reply: ReplySlot = if (terminal_panes) undefined else {}, reply_len: u16 = 0, /// THE TRANSIENT MESSAGE: what just happened to this pane, drawn on its /// LAST row until the next key or mouse event wipes it (see update). Fixed /// and inline like `reply` above — a message is one short line, so a pane /// that never sees one still costs nothing to carry it, and there is no /// allocation to fail at the moment something is trying to be reported. /// Written in exactly one place, Pardes.setMessage, by a SHELL. msg: [256]u8 = undefined, msg_len: u16 = 0, fn tagSlice(p: *const Pane) []const u8 { return p.tag_tail[0..p.tag_tail_len]; } fn promptAt(p: *const Pane) ?u16 { return switch (p.prompt) { .none => null, .search, .pipe, .save => |at| at, }; } fn hasSearchPrompt(p: *const Pane) bool { return switch (p.prompt) { .search => true, else => false, }; } fn hasPipePrompt(p: *const Pane) bool { return switch (p.prompt) { .pipe => true, else => false, }; } fn hasSavePrompt(p: *const Pane) bool { return switch (p.prompt) { .save => true, else => false, }; } fn appendTag(p: *Pane, text: []const u8) bool { if (text.len > p.tag_tail.len - p.tag_tail_len) return false; @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); p.tag_tail_len += text.len; return true; } fn insertTagByte(p: *Pane, at: usize, byte: u8) bool { if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false; std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]); p.tag_tail[at] = byte; p.tag_tail_len += 1; return true; } fn removeTagByte(p: *Pane, at: usize) void { if (at >= p.tag_tail_len) return; std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]); p.tag_tail_len -= 1; } pub fn cwdSlice(p: *const Pane) []const u8 { return switch (p.cwd) { .none => "", .owned => |dir| dir, .inherited => |src| src.cwdSlice(), }; } /// Shell-reported directory: own the bytes in cwd_buf. pub fn setOwnedCwd(pane: *Pane, dir: []const u8) void { const n = @min(dir.len, pane.cwd_buf.len); @memcpy(pane.cwd_buf[0..n], dir[0..n]); pane.cwd = .{ .owned = pane.cwd_buf[0..n] }; } pub fn isTerminal(pane: *const Pane) bool { const no_pdf = if (comptime pdf_enabled) pane.pdf == null else true; return pane.file == null and pane.image == null and no_pdf; } /// The one coloring choice keyed on what a pane IS, so the highlight /// producer (refreshHighlights) and the render pass agree on the algorithm. pub const ColorAlgo = enum { none, tty, source, diff }; pub fn colorAlgo(pane: *const Pane) ColorAlgo { if (pane.isTerminal()) return .tty; if (pane.file) |f| { if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; return .source; } return .none; } pub fn pdfPath(pane: *const Pane) ?[]const u8 { if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; return null; } pub fn pdfPage(pane: *const Pane) ?usize { if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.page; return null; } /// Surface row of shell row `g`. The edit buffer's lines stand in for the /// `rows` shell rows it covers, so everything below it slides by the /// difference — the identity on files and on terminals nobody has typed /// a newline into, which is why the rest of the row math can stay naive. pub fn surfRow(pane: *const Pane, g: i32) i32 { const o = pane.ovl orelse return g; if (g <= o.row) return g; const lines: i32 = @intCast(modal.lineCount(o.text)); if (g >= o.row + o.rows) return g + lines - o.rows; return @min(g, o.row + lines - 1); // inside the buffer: its own rows } /// the inverse; every surface row inside the edit buffer maps to its anchor pub fn gridRow(pane: *const Pane, s: i32) i32 { const o = pane.ovl orelse return s; if (s <= o.row) return s; const lines: i32 = @intCast(modal.lineCount(o.text)); if (s < o.row + lines) return o.row; return s - lines + o.rows; } /// current scroll offset: file top line, or the scrollback offset pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); if (comptime pdf_enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); return pane.surfRow(term_pane.gridOffset(pane)); } /// The document position a BODY ROW begins at — `vr` 0 is the first row /// under the tag. The screen->document half of the wrap map, and the half /// the mouse asks: a click lands on the character the user can see, which /// is last frame's arrangement, which is what the map holds. pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; // unwrapped: rows ARE lines, and the byte column a row starts at is the // horizontal scroll (always 0 on a terminal, which never has one) return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; } /// ...and back: the body row `line`:`col` renders on, plus the byte column /// that row starts at — subtract it from a document column to get a screen /// one. `row` is -1 when the position is not on screen, which only a /// wrapped body ever says: unwrapped the arithmetic answers for any line at /// all and the callers' own bounds checks do the rejecting, as before. pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; var i: u16 = 0; while (i < pane.wrap_n) : (i += 1) { if (pane.wrap_line[i] != line) continue; // the LAST row of a line owns every column past its start, so a // cursor parked on the trailing newline still has somewhere to draw if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; } return .{ .row = -1, .at = 0 }; } /// ponytail: `delta` is LOGICAL LINES, wrapped or not — one `j` past the /// bottom scrolls a whole line even when that line is five screen rows, and /// a wheel tick or a Ctrl-d page counts lines rather than rows. So a body /// full of long lines scrolls in jumps, and the view can never sit at the /// MIDDLE of a wrapped line. That is the ceiling the whole feature buys its /// smallness with: wrap is render + hit-test and nothing else in the editor /// knows about it. The upgrade is to make f.scroll a (line, row-within-line) /// pair, which every reader of it — this, the scrollbar, the syntax window, /// bodyText, ensureCursorVisible, the gutter click — would then have to /// learn; do that when scrolling long lines actually annoys someone. fn scrollBy(pane: *Pane, delta: i32) void { if (pane.file) |*f| { const max: i64 = @intCast(file_pane.nlines(pane.gpa, f) -| 1); const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); const next: usize = @intCast(n); if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } else if (hasPdf(pane)) { if (comptime pdf_enabled) { const pv = &pane.pdf.?; const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); pv.text_scroll = @intCast(std.math.clamp( @as(i64, @intCast(pv.text_scroll)) + delta, 0, max, )); } } else { // the vt scrolls in SHELL rows; convert through the edit buffer const off = term_pane.gridOffset(pane); term_pane.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); } } pub fn ensureCursorVisible(pane: *Pane) void { // scrolloff margin, shrunk on short panes so the band stays non-empty var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); // A WRAPPED body shows fewer LINES than it has rows, and scrolling is // still by line, so the bottom of the view is not off+rows-1 — a long // line at the bottom would leave the cursor below the last row it can // actually see. What the map is asked for is the COUNT of lines that // fit, not which ones: this runs on every cursor move and the map is // last FRAME's, but several keys can arrive between two renders (an // autorepeated j, a paste) and then its absolute line numbers name a // scroll offset that has already moved on — reading them cost a // batched j four extra lines of scroll per keystroke. A count is // scroll-independent, and when nothing wrapped it is exactly `rows`, // so this whole block is a no-op on an unwrapped body and the margin // clamp below reduces to the short-pane one above it. // // ponytail: last frame's line count applied to this frame's offset. It // is exact whenever a render happened in between (the normal case) and // an estimate mid-batch, self-correcting on the next key. The exact // answer is to re-walk the lines from `off` accumulating wrapped // heights — do that when a batch visibly lands the cursor off screen. var last = off + @as(i32, pane.rows) - 1; if (pane.wrap_n > 0) { const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[0]; last = off + lines_shown; margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); } if (pane.cur_row < off + margin) { pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 } else if (pane.cur_row > last - margin) { // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); // terminals overshoot harmlessly — the vt clamps at the live bottom var to = pane.cur_row + margin; if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1))); if (comptime pdf_enabled) { if (pane.pdf) |pv| to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); } pane.scrollBy(@max(0, to - last)); } // the horizontal mirror, files only: keep scroll_off columns of // context around the cursor (wheel-driven hscroll is exempt — it // never moves the cursor, and a cursor move pulls the view back). // A wrapped body has nothing to scroll sideways, and its hscroll is // left ALONE rather than zeroed: turn the wrap back off and the view // you had is still there. if (pane.file) |f| { if (pane.wrap_n != 0) return; const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W)); const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))); const raw_cur: usize = @intCast(@max(0, pane.cur_col)); const raw_scroll: usize = @intCast(@max(0, pane.hscroll)); const cur = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_cur))); const visual_scroll = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_scroll))); var target = visual_scroll; if (cur < visual_scroll + hmargin) target = @max(0, cur - hmargin) else if (cur > visual_scroll + w - 1 - hmargin) target = cur - (w - 1 - hmargin); if (target != visual_scroll) pane.hscroll = @intCast(file_pane.rawAtDisplay(line, @intCast(target))); } } fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; if (pane.file != null or hasPdf(pane)) { pane.cur_row = pane.scroll(); pane.cur_col = 0; } else { const cur = term_pane.gridCursor(pane); pane.cur_row = pane.surfRow(@as(i32, cur.y) + term_pane.gridOffset(pane)); pane.cur_col = @intCast(cur.x); } pane.cur_pinned = true; } }; const Drag = union(enum) { none, /// `corner` is what makes this a CORNER grab: the press landed on a cell /// that is both this v-border and one of the two adjoining columns' own /// h-borders, and then the one drag moves both boundaries — cur_x the /// column pair, cur_y `corner.col`'s pane pair at index `corner.idx`. /// null is a plain edge drag and cur_y is only carried along for the /// preview. The drag is a `border_v` on left_col either way; only the row /// half changes which column it belongs to. /// /// Both adjoining columns count, left_col FIRST. The v handle IS left_col's /// last cell, so left_col's horizontal hint is drawn straight THROUGH it /// and its crossing reads as a full cross; the right column's spans start /// one cell further right, so its crossing reads as a T butting into the /// junction. Either way the two lines meet AT the handle cell, which is /// what makes both grabbable. /// /// ponytail: a corner still moves exactly TWO boundaries, never three, so /// when BOTH columns happen to be split at the grabbed row the LEFT one /// wins and the right column's seam is left alone — the gesture that /// existed before is bit-for-bit unchanged. 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 }, /// a left sweep along a pane's TAG row: it drives the tag's own cursor and /// selection (rendered-tag columns) rather than the body's block selection, /// which is what makes a one-line tag select like a line of text 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: pdf_pane.PointerDrag = .{}, }, /// The physical button whose release balances this gesture. Layout and /// tag drags are all left-button gestures; a text selection remembers its /// own acme button. Deriving this from the gesture keeps multi-button /// chords exact without a parallel held-button mask. fn button(drag: Drag) ?Mouse.Button { return switch (drag) { .none => null, .border_v, .border_h, .move, .tag => config.select_button, .select => |selection| selection.button, }; } }; // The two border clamps, pulled out as plain arithmetic on plain numbers for // one reason: a CORNER drag runs both of them off the same mouse position, and // the thing that has to hold is that each one only ever looks at its own axis. // A clamp that consulted the other axis — or a single "is this point legal" // test over the pair — would freeze the whole gesture the moment either edge // hit its stop, when what the hand expects is the free axis to keep tracking // and the pinned one to sit at the wall. Being pure, they are also the piece // worth a test; see below. /// Where a vertical border drag settles: `mcol` clamped so neither column of /// the pair falls under MINW. `lx`/`lw` are the left column's x and width, /// `rw` the right column's. Degenerate pairs (a window too narrow to hold two /// minimums at all) pass the mouse through rather than snapping to a lie. 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; } /// Where a horizontal border drag settles: `mrow` clamped so either pane may /// shrink to just its tag row (BOX_H) but no further. `ay`/`ah` are the upper /// pane's y and height, `bh` the lower pane's. /// /// The handle is the seam row that is a BODY row, which is the upper pane's /// LAST row normally and — with Tagbottom, where that row is the upper pane's /// tag — the lower pane's FIRST. That is the same seam one row further down, /// so both walls simply move with it. 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; // the walls, spelled out for tags-on-top (the tag_bottom = false below), // where the handle is the upper pane's LAST row: the upper pane bottoms out // with its tag row alone at ay, and the lower pane does the same one row // above the pair's end 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)); // Tagbottom moves the handle to the LOWER pane's first row, one further // down, and both walls travel with it — either pane still bottoms out at // its bare tag row and neither can be squeezed away 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)); } /// The screen row that is the handle between column `c`'s pane pair `k` and /// `k+1`: the upper pane's LAST body row, or with Tagbottom — where that row is /// the upper pane's tag — the lower pane's FIRST. The one place this rule /// lives; the h hit test, the corner search and the hover hint all read it here. fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 { const r = p.rects[p.col_terms[c][k]]; return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1; } /// The corner fixture: the classic two-column boot with a SECOND pane added to /// the RIGHT column, so both columns have a seam of their own and the v handle /// between them can find either. 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_terms[1][0]; p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column p.sync(); return p; } /// Moves column 1's own seam off column 0's, by the ordinary h-border gesture, /// and answers the row it landed on. Both columns split at the SAME row is the /// tie case, which is a different test. 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_terms[0][0]].?.vweight; const v_right = p.panes[p.col_terms[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_terms[1][0]].?.vweight != v_right); try std.testing.expectEqual(v_left, p.panes[p.col_terms[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_terms[0][0]].?.vweight; const v_right = p.panes[p.col_terms[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_terms[0][0]].?.vweight != v_left); try std.testing.expectEqual(v_right, p.panes[p.col_terms[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_terms[0][0]].?.vweight; const v_right = p.panes[p.col_terms[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_terms[0][0]].?.vweight); try std.testing.expectEqual(v_right, p.panes[p.col_terms[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_terms[1][0]]; const b = p.rects[p.col_terms[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; // 124 deliberately makes the thirty-one-cell source split into unequal // rounded halves. Independent width rounding moved the right column by a // cell here; cumulative boundaries keep it pinned. const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(p.layoutSplitColumn(0, 1, false)); _ = try p.newShell(2, ""); try std.testing.expect(p.layoutSplitColumn(0, 2, true)); p.sync(); // ids 2, 0, 1 now own 1/4, 1/4 and 1/2 of the screen. Splitting the // middle column must consume its own thirty cells in place: the columns // on both sides retain their exact rectangles, not merely their weights. const left_before = p.rects[2]; const right_before = p.rects[1]; _ = try p.newShell(3, ""); try std.testing.expect(p.layoutSplitColumn(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(!narrow.layoutSplitColumn(0, 1, false)); try std.testing.expectEqual(@as(usize, 1), narrow.ncol); // Public callers may chain surgery before sync. The first split leaves // two minimum-width columns, so the second must read the freshly-derived // source width and refuse rather than consulting the old full-width rect. 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(sequential.layoutSplitColumn(0, 1, false)); _ = try sequential.newShell(2, ""); try std.testing.expect(!sequential.layoutSplitColumn(0, 2, false)); try std.testing.expectEqual(@as(usize, 2), sequential.ncol); // Even a deliberately coarse restored proportion divides into two usable // columns. Rebase preserves every ratio while giving an odd numerator an // exact half instead of rendering weight 1:2 from the value 3. 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(coarse.layoutSplitColumn(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]); // A failed rebase is transactional even when the source pane is being // MOVED out of a stack. Previously absorb/remove ran before overflow was // discovered, so false meant the pane had silently vanished. const extreme = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20 }); defer extreme.deinit(); _ = try extreme.newShell(1, ""); try std.testing.expect(extreme.layoutSplitColumn(0, 1, false)); _ = try extreme.newShell(2, ""); extreme.layoutInsert(0, 1, 2); extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double extreme.col_weight[1] = 1; extreme.computeGeom(); const terms_before = extreme.col_terms; 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(!extreme.layoutSplitColumn(0, 0, false)); try std.testing.expectEqual(terms_before, extreme.col_terms); 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(); // Restore accepts each individual weight up to maxInt/6. Deleting three // one-pane columns can legitimately coalesce four such values into an odd // survivor above maxInt/2, which cannot be globally doubled for a split. for (1..4) |id| { _ = try p.newShell(id, ""); try std.testing.expect(p.layoutSplitColumn(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].?; p.layoutRemove(id); p.deinitPane(pane); 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(!p.layoutCanSplitColumn(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(p.layoutSplitColumn(0, 1, false)); p.sync(); const untouched = p.rects[1]; p.settings.panel_transition = .slide; _ = try p.newShell(2, ""); try std.testing.expect(p.layoutSplitColumn(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.panel_tracks[1] == null); try std.testing.expect(p.panel_tracks[0] != null); try std.testing.expect(p.panel_tracks[2] != null); try std.testing.expectEqual(panel_animation.Phase.moving, p.panel_tracks[0].?.phase); try std.testing.expectEqual(panel_animation.Phase.opening, p.panel_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..panel_animation.Transition.slide.frames()) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); p.settings.panel_transition = .zoom; p.update(.{ .resize = .{ .cols = 101, .rows = 17 } }); try std.testing.expect(!p.animationActive()); for (p.panel_tracks) |track| try std.testing.expect(track == null); } test "vertical close samples only a canonical baseline containing that pane" { // Deleting an opener before its first canonical frame must not animate // unrelated cells from the older global baseline as though they belonged // to the short-lived 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(p.layoutSplitColumn(0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); p.acknowledgePanelPresentation(opening.panelTracks()); p.removePane(1); p.sync(); try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks); } // 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(p.layoutSplitColumn(0, 1, false)); p.sync(); for (0..panel_animation.Transition.vertical.frames()) |_| p.update(.tick); _ = frame.reset(.retain_capacity); const canonical = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len); p.acknowledgePanelPresentation(canonical.panelTracks()); const serial = p.panes[1].?.serial; p.removePane(1); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.nclosing_panel_tracks); try std.testing.expectEqual(serial, p.closing_panel_tracks[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(p.layoutSplitColumn(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.panel_cell_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()); // A second opener before the first canonical endpoint has no truthful // single old grid. Submit this layout canonically instead of rewinding to // the boot baseline or manufacturing a stale closing pane. _ = try p.newShell(2, ""); try std.testing.expect(p.layoutSplitColumn(1, 2, false)); p.sync(); try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready); try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks); for (p.panel_tracks) |track| try std.testing.expect(track == null); try std.testing.expect(p.panel_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(p.layoutSplitColumn(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.panel_diff_pending and !p.panel_diff_ready); _ = 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(runtime_cfg.find("PanelZoom").?, null); try std.testing.expectEqual(panel_animation.Transition.zoom, p.settings.panel_transition); try std.testing.expect(p.panel_presentation_pending); try std.testing.expect(p.presentedPointer(10, 4) == null); for (p.panel_tracks) |track| try std.testing.expect(track == null); // Once canonical has replaced that sample, make another real transition. // Re-applying the effective tagline percentage is inert, but changing it // invalidates the frozen raster's metrics and therefore snaps the tracks. 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(runtime_cfg.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(runtime_cfg.find("TaglineSize").?, changed_text); try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent); try std.testing.expect(p.panel_presentation_pending); for (p.panel_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: panel_animation.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(Pardes.PointerCell{ .col = 21, .row = 7 }, p.presentedPointer(30, 8).?); // Canonical cells covered only by the not-yet-arrived target are inert. try std.testing.expect(p.presentedPointer(2, 2) == null); // Unrelated screen space remains in the ordinary grid coordinate system. try std.testing.expectEqual(Pardes.PointerCell{ .col = 100, .row = 10 }, p.presentedPointer(100, 10).?); p.acknowledgePanelPresentation(&.{panel_animation.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.presentedPointer(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 = [_]panel_animation.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 }, } }; // Slot one is deliberately absent from the fixture, so install this // synthetic overlap directly; the production acknowledgement rejects // dead pane lifetimes before they can participate in input. p.presented_panel_tracks = @splat(null); p.presented_panel_tracks[0] = overlap[0]; p.presented_panel_tracks[1] = overlap[1]; try std.testing.expectEqual(Pardes.PointerCell{ .col = 65, .row = 3 }, p.presentedPointer(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(p.layoutSplitColumn(0, 1, false)); p.sync(); try std.testing.expect(p.panel_presentation_pending); try std.testing.expect(p.presentedPointer(10, 4) == null); var tracks: [MAX_PANES]panel_animation.Track = undefined; var len: usize = 0; for (p.panel_tracks) |maybe| if (maybe) |track| { tracks[len] = track; len += 1; }; p.acknowledgePanelPresentation(tracks[0..len]); try std.testing.expect(!p.panel_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.presentedPointer(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(p.layoutSplitColumn(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 = Pardes.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.panel_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.panel_tracks[0] orelse return error.MissingSecondRetarget; try std.testing.expect(target_c.from.eql(shown_a)); try std.testing.expect(target_c.to.eql(Pardes.panelBox(p.rects[0]))); try std.testing.expectEqual(panel_animation.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); var submitted: ?panel_animation.Track = null; for (latest.panelTracks()) |track| if (track.pane == 0) { submitted = track; break; }; const track = submitted orelse return error.MissingSubmittedRetarget; p.acknowledgePanelPresentation(latest.panelTracks()); try std.testing.expect(p.presented_panel_layout[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(p.layoutSplitColumn(0, 1, false)); p.sync(); const first = p.panel_tracks[1] orelse return error.MissingOpeningTrack; try std.testing.expectEqual(panel_animation.Phase.opening, first.phase); _ = try p.newShell(2, ""); try std.testing.expect(p.layoutSplitColumn(1, 2, true)); p.sync(); const retargeted = p.panel_tracks[1] orelse return error.MissingOpeningRetarget; try std.testing.expectEqual(panel_animation.Phase.opening, retargeted.phase); try std.testing.expect(retargeted.from.eql(first.from)); } test "core composes ASCII bytes while visual-only cells pass through" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); defer p.deinit(); var current: [4]Cell = @splat(.{ .default = false }); var previous = current; for (¤t, "Dwxq") |*cell, byte| cell.text[0] = byte; for (&previous, "Azxq") |*cell, byte| cell.text[0] = byte; current[2].style.bold = true; // visual-only: same glyph, new style current[3].text[0..2].* = .{ 0xc3, 0xa9 }; current[3].len = 2; // non-ASCII: canonical immediately const diffs = [_]PanelCellDiff{ .{ .ascii = .{ .from = 'A', .to = 'D' } }, .{ .ascii = .{ .from = 'z', .to = 'w' } }, .visual, .visual, }; var surface: Surface = .{ .cols = 4, .rows = 1, .cells = ¤t, .previous_cells = &previous, .cell_diffs = &diffs, }; surface.panel_tracks[0] = .{ .effect = .ascii, .frame = 2, .frame_count = 4, .from = .{ .w = 4, .h = 1 }, .to = .{ .w = 4, .h = 1 }, }; surface.npanel_tracks = 1; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const presented = try 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(); // Row zero is sliding in from the left screen edge: it holds real glyphs // from two columns further right, and the cells its text has not reached // yet keep the frozen old grid rather than a blend or a blank. 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 = panel_animation.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: panel_animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.panel_cell_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.panel_cell_diffs[row * p.screen_w + col] = .{ .ascii = .{ .from = ' ', .to = @intCast(' ' + (col - 4) % 12 + 1), } }; } } p.panel_diff_ready = true; for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; const track: panel_animation.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 => panel_animation.dissolveRevealed( pane.serial, rcol, rrow, track.amount(), ), .ascii => switch (p.panelCellDiff(col, row)) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; const mapped = p.presentedPointer(col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| try std.testing.expectEqual(Pardes.PointerCell{ .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.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.panel_cell_diffs, .unchanged); p.panel_diff_ready = true; const box: panel_animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = effect, .from = box, .to = box, }}); try std.testing.expectEqual(Pardes.PointerCell{ .col = 8, .row = 3 }, p.presentedPointer(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: panel_animation.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; var track: panel_animation.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.panel_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); // The motion intent itself survives temporary invisibility. When a later // successfully presented sample materializes under the stationary raw // pointer, it can begin a fresh delay without synthetic mouse motion. 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 = Pardes.panelBox(p.rects[0]); var track: panel_animation.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.panel_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.presentedPointer(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(p.layoutSplitColumn(0, 1, false)); p.sync(); // Make the pair 418/561 through the real divider path. This ratio was a // counterexample for f32 weights: splitting the right side twice changed // the already-created middle boundary by one cell. 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(p.layoutSplitColumn(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(p.layoutSplitColumn(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 = struct { x: u16, y: u16, w: u16, h: u16 }; const LayoutSnapshot = struct { serial: u32, box: panel_animation.Box, }; pub const Options = struct { tty_only: bool = false, /// initial shell panes: 1 (default) or 3 for the classic two-column boot. /// A `file` outranks this — see there. shells: u8 = 1, /// argv FILE (resolved absolute): boot with it as the ONLY pane, focused /// and filling the window. It names the whole boot layout, so `shells` is /// not consulted at all (it never was — the file arm always won). file: ?[]const u8 = null, file_line: usize = 0, 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`: run a full session even inside another pardes. The core /// never reads it; it rides here because it is the shells that would /// otherwise open the nested.zig socket, and this is the way argv already /// reaches them. nested: bool = false, /// Native main fills this with the contents of the per-user config init. /// Keeping discovery out of the core makes constructors and web builds /// deterministic; when present, each line is dispatched as a builtin /// before init returns and therefore before any frontend can render. startup_config: ?[]const u8 = null, /// SERVE ACME'S CONTROL FILESYSTEM for this session (`--fs`), and where. /// `null` is off; `""` means "derive the mount point" (a per-session /// directory under `$XDG_RUNTIME_DIR`); anything else is the directory /// `--fs=` named, which scripts and the snapshot harness need because /// they have to predict it. /// /// One field rather than a flag plus a path: two of those encode a state /// ("no filesystem, mounted here") that means nothing. The core never /// mounts anything — a mount is a host's business, and one host (the /// browser) has no filesystem at all — but the option rides here because /// argv already reaches the shells this way, like `nested`. fs: ?[]const u8 = null, /// Native launcher's resolved per-user `pardes` directory. Relative /// ThemeFile operands and DumpThemes are rooted here. Null for web and /// direct core callers which did not opt into per-user configuration. config_dir: ?[]const u8 = null, /// ...and WHERE that came from, which is a separate fact: the path /// resolves even when the file does not exist, and that is precisely the /// case the Config builtin is asked about. Null on the web and in every /// core test, where there is no per-user config to name. 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, /// Initial grid. Shell contract: for LIVE sessions leave these at the /// defaults and deliver the real size as the first resize EVENT — the core /// defers an integrated shell's greeting until after a resize AND its OSC /// 133 B input mark (so `ls` cannot race startup and wraps to the real pane /// width); pre-sizing here means that resize never fires and the greeting /// never runs. Pre-size only for dump loads (nothing greets, and it avoids /// reflowing replayed content twice). cols: u16 = 80, rows: u16 = 24, }; const PendingPipe = struct { id: u32, pane: usize, serial: u32, revision: u32, command: []u8, cwd: []u8, inputs: []selection_pipe.Input, ranges: [MAX_SELS]modal.HxRange, primary: u8, explicit: bool, fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { gpa.free(wait.command); gpa.free(wait.cwd); for (wait.inputs) |input| gpa.free(@constCast(input.bytes)); gpa.free(wait.inputs); wait.* = undefined; } fn request(wait: *const PendingPipe) selection_pipe.Request { return .{ .id = wait.id, .command = wait.command, .cwd = wait.cwd, .inputs = wait.inputs, }; } }; /// The acme verb the core just performed, for a shell that can answer with /// something physical. macOS taps the trackpad under the finger that asked /// (NSHapticFeedbackManager); the SDL shell already does the same thing with a /// gamepad — `rumble` in src/gui/deck.zig, "a brief gentle ack for /// execute/look, not a buzz". Two verbs rather than one flag because they /// deserve to feel different: Exec did something, Look went somewhere. 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; /// Deferred pane teardown. A dropped pane's memory outlives the frame it died /// in: it is doomed here and actually torn down one full frame later, so any /// `*Pane` captured that frame — an effect, another pane's inherited cwd — /// stays valid long enough for the per-frame fixup pass to repair it. `fresh` /// holds this frame's drops, `stale` the previous frame's, freed next reap. pub const PaneAllocator = struct { fresh: [2 * MAX_PANES]?*Pane = @splat(null), stale: [2 * MAX_PANES]?*Pane = @splat(null), fn doom(a: *PaneAllocator, pane: *Pane) void { for (&a.fresh) |*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; } fn isDoomed(a: *const PaneAllocator, pane: *const Pane) bool { for (a.fresh) |s| if (s == pane) return true; for (a.stale) |s| if (s == pane) return true; return false; } }; 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 = .{}, resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), /// Deferred teardown of dropped panes (see PaneAllocator). pane_alloc: PaneAllocator = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, /// Fixed-point horizontal proportions. Integer sums make splitting W /// into A+B exactly associative, so an unrelated boundary cannot move /// through floating-point regrouping after repeated source-local splits. col_weight: [MAX_COLS]u64 = @splat(column_weight_unit), col_terms: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), // derived each sync rects: [MAX_PANES]Rect = undefined, col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, /// Last committed layout and the finite visual tracks derived from it. /// Both are indexed by pane slot; serial rejects slot reuse. layout_snapshot: [MAX_PANES]?LayoutSnapshot = @splat(null), layout_snapshot_ready: bool = false, /// Boot/config replay is not a visible layout event, and a host resize is /// already continuous physical motion. Those paths snap this presentation /// cache once instead of manufacturing panel transitions. panel_animation_enabled: bool = false, snap_panel_layout_once: bool = false, /// Tracks the core is preparing for the next frame, and the independent /// tracks a backend has actually put on screen. Input must follow the /// latter: a failed GPU submit or a delayed AppKit draw cannot make an /// unpresented animation tick clickable. panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null), presented_panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null), /// Deleted pane lifetimes cannot stay in the slot-indexed live table: a /// slot may be reused while its old pixels are still leaving. Tombstones /// are dense plain records and never retain a functional Pane. closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined, nclosing_panel_tracks: usize = 0, presented_closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined, npresented_closing_panel_tracks: usize = 0, /// Last canonical grid a backend explicitly acknowledged, and the frozen /// semantic diff from it to the first canonical frame of this transition. /// These buffers are capacity-reused and own no pane resources. presented_cells: []Cell = &.{}, presented_cells_cols: u16 = 0, presented_cells_rows: u16 = 0, presented_cells_valid: bool = false, /// Pane lifetimes and boxes represented by `presented_cells`. This is /// deliberately separate from `presented_panel_layout`, which follows /// partially animated pixels. A closing tombstone may sample the frozen /// grid only when that grid actually contains the exact pane box. presented_cells_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), panel_cell_diffs: []PanelCellDiff = &.{}, panel_diff_pending: bool = false, panel_diff_ready: bool = false, /// Canonical pane lifetimes/boxes captured by render, and the visual boxes /// from the last successful acknowledgement. Retargeting reads the latter, /// never a logical layout which may not have reached the screen yet. submitted_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), submitted_panel_layout_ready: bool = false, presented_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), /// Becomes true on the first host acknowledgement. Sans-host unit callers /// retain direct canonical pointer semantics; a real frontend thereafter /// gets the strict unpresented interval below. panel_presentation_ready: bool = false, /// A layout mutation has not reached a backend yet. Pointer gestures are /// inert during this normally sub-frame interval; guessing frame-zero /// geometry would make queued input address pixels nobody has presented. panel_presentation_pending: bool = false, active: usize = 0, /// THE focus history: where the keyboard has BEEN, oldest first, and `jcur` /// is the entry it is at NOW (so `jumps[jcur]` is always the live spot and /// the entries past it are the ones Ctrl-i walks forward into). Maintained /// in exactly one place — sync() — and read by everything that asks "where /// was I": Back/Forward, Last, the Jumplist buffer, prevFocus when a pane /// closes, Last, and the directory order a look resolves in. /// /// One list, not two. A jumplist kept beside a focus history is two things /// to keep agreeing, and they would disagree the first time one of them /// forgot a pane the other still names. jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, /// THE PANES THAT HAVE LOOKED, oldest first, at most one entry each — the /// spine n/N walks (lookWalkPanes). Serials rather than slots, for the /// same reason the jumplist stores them: a freed slot is reused, and an /// entry naming a dead pane must not resolve to the newcomer sitting in /// its place. /// /// Not the jumplist, though it looks like one. `jumps` records where FOCUS /// has been, and a look moves focus to what it OPENED; this records where /// the look was made FROM, which is the pane holding the list you are /// working through. The two answer different questions and would only /// coincide by accident. look_src: [MAX_PANES]u32 = undefined, n_look_src: usize = 0, /// Serial of the pane whose stream n/N is actively walking. Focus may /// leave it when Enter/Look opens a row; provenance does not. Null/stale /// falls back to the ordinary history order. look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, /// Every user-settable, queryable display/runtime choice in one plain /// authoritative record. Generated setting builtins mutate it directly; /// rendering and the Config report read those same fields. settings: runtime_cfg.State = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, /// One theme-derived Ghostty palette shared by all filtered terminals. /// Its value key makes a same-name ThemeFile reload invalidate it without /// coupling terminal rendering to the theme-selection call sites. tty_filter_palette: term_pane.FilterPalette = .{}, /// Delivery bookkeeping, not configuration: prevents a synchronous host /// pump from taking one still-pending font request more than once before it /// acknowledges success or rejection. font_request_taken: bool = false, /// One user theme loaded from a .zon file. The parsed value owns its name; /// all color fields are inline. `theme_file_generation` makes a queued IO /// request stale as soon as another ThemeFile/Theme/NextColor command wins. custom_theme: ?Theme = null, custom_theme_active: bool = false, /// Sized by `runtime_cfg.host_path_cap`, which is 0 where the platform has /// no filesystem to hold a theme file: `set` then refuses every non-empty /// path and `themeFileRequest` answers `PathTooLong`, which is the honest /// answer on a board whose only IO is a UART. theme_file_path: runtime_cfg.Text(runtime_cfg.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), /// False only while startup configuration or a dump restore is selecting /// its first theme. No frame is rendered in that interval. animate_theme_changes: bool = false, /// Native pixel attachments supported by the shell (Kitty graphics in a /// terminal, GPU textures in SDL). Image panes dynamically fall back to /// the PETSCII matcher without it. native_images: bool = false, quit: bool = false, /// The Look or Exec that has happened and not yet been felt, taken by the /// shell once per pump (takeHaptic). A pulse, not a queue: five Execs /// inside one keystroke are still one thing the hand did. 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, /// True only after buttonless motion. Presentation acknowledgements may /// remap that stationary hover; keyboard/button/wheel input clears the /// intent so an ordinary repaint cannot silently re-arm a cancelled hint. raw_hover_intent: bool = false, /// Hosts explicitly tell us when the pointer leaves. Keeping this bit /// separate from the last coordinates lets a drag retain its endpoint /// while idle resize-handle hints disappear immediately outside a window. 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 {}, /// touchpad drift guard, counted down in horizontal wheel ticks — see /// config.wheelTick, which owns the whole rule. Global, and clock-free on /// purpose: the core has no clock, so "recently scrolled vertically" can /// only mean "in the last few wheel events", which is all the heuristic /// needs. /// ponytail: it therefore never times out — only a horizontal tick spends /// it, so a sideways swipe an hour after a scroll still pays the toll. If /// that ever bites, clear it on any non-wheel event: a keypress or a click /// is proof the gesture ended, and still needs no clock. wheel_guard: u8 = 0, ctrl_w_pending: bool = false, /// A key is being REPLAYED over several selections (replaySels). The one /// thing the replay cannot do is let a per-pass action that is really a /// per-KEYSTROKE action fire once per range: the undo snapshot must be /// taken once, the yank register accumulates instead of being overwritten, /// and anything that opens, closes or focuses a pane (runBuiltin) or asks /// the language backend (lspRequest) happens on the first pass and stops /// the replay dead — see multiOnce. multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, /// SPC leader in flight, holding the key path typed so far (empty = just /// SPC). Global like ctrl_w_pending — there is one leader and it acts on /// the active pane, whose transient message row shows the pending path. leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, /// the TOPBAR holds the keyboard, parked at this UTF-8 byte offset of the /// row-0 line. Global like leader_on for the same reason: row 0 is not a /// pane and never will be, so its one piece of focus state cannot live on /// one. `null` = the panes have the keyboard, which is every other frame. topbar_col: ?u16 = null, 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, /// The one language query in flight. ONE, deliberately: every one of these /// is a keystroke the user is waiting on, so a second press means "I meant /// this one" — the id bump makes the older answer stale and lspResponse /// drops it. A queue would only buy the right to render an answer nobody /// is waiting for any more. /// `arg` holds the replacement name for rename and the query for workspace /// symbols. `serial` rejects a response after its pane slot was reused; /// `revision` makes a mutating rename conditional on the source snapshot /// the worker actually analysed. `row`/`col` are where the cursor was when /// the question was asked. Only `completion` reads them, and only to undo /// itself: Tab diverted instead of indenting, so an empty answer has to put /// the indent back — but only if the cursor has not moved since. 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, /// One current shell-filter request. A newer submit frees and supersedes /// it; old worker answers then fail the id check. The request itself owns /// every byte a shell snapshots while draining the id-only effect. pipe_seq: u32 = 0, pipe_wait: ?PendingPipe = null, /// acme's control filesystem, when a host serves one (`pardes --fs`). /// Zero-initialised and inert: a core nobody scripts pays for one branch /// per edit and nothing else. See src/acmefs.zig. fs: acmefs.State = .{}, /// Pending effects, drained by the shell after each update. The bounded /// ring preserves byte order; once full, later effects are refused so no /// already-queued write can be reordered or silently evicted. effects: [EFFECT_CAP]Effect = undefined, effects_head: usize = 0, effects_len: 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: Fallback, /// Input the loop has not consumed yet. Single-threaded: a host's worker /// threads keep their own thread-safe inbox and post from the loop thread, /// which is what keeps this ring lock-free. in_q: [64]Event = undefined, in_head: usize = 0, in_len: usize = 0, /// helix's DEFAULT register (gpa-owned): what `y`/`d`/`c` write and /// `p`/`P`/`R` read. Never the system clipboard — `SPC y`/`SPC p` are the /// two commands that cross that line. yank: ?[]u8 = null, /// a `SPC p`/`SPC P`/`SPC R` waiting on the shell's clipboard read, or /// null. At most one: a second request replaces the first, and any /// keystroke abandons it (update). clip_pending: ?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, 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 term_pane.RowsCache for the lifetime rule shell_rows: term_pane.RowsCache = .{}, 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, .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 (opts.file) |path| { // FILE argv boot: the doc alone, filling the window. Naming a file // is asking to READ it, not to be handed a shell you did not ask // for and have to close — and the launch directory is one Newcol // away when it is wanted. Doc, PDF and image all boot the same way. _ = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) break :initial_doc try pdf_pane.openPane(p, 0, path, opts.file_line); if (look.isImagePath(path)) break :initial_doc try image_pane.create(p, 0, path, &.{}); break :initial_doc try file_pane.open(p, 0, path, opts.file_line); }; p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; } else if (comptime platform == .p4) { // BARE METAL BOOTS AN EMPTY OUTPUT BUFFER, and a shell is not a layout preference // here but an impossibility: there is no operating system under this, so there is // nothing to fork and no pty to give a terminal pane. Booting one anyway produced // exactly what that describes - a pane whose tag ends in `Filter`, whose pty is the // Fallback's silent one, with no gutter, no buffer, and no key that reaches anything. // Measured on an ESP32-P4 over the serial line: every keystroke vanished. // // An output buffer is the right default rather than a file pane, and not only because // `opts.file` cannot work here (the P4's embedded allowlist is empty by design - see // source_manifest.zig - so `look.readFile` has nothing to resolve a path against). It // is what the platform's own words WANT: `Peek`, `Poke` and `Hexdump` each fill an // output buffer, so booting into one means the first dump lands in the same kind of // pane the boot pane already is. It is editable text with no file behind it, which is // the honest description of a buffer on a board with no filesystem. const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); _ = try output_pane.open(p, 0, "", .{ .cmd = .New }, "", content); p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; } else if (opts.tty_only) { _ = try p.newShell(0, ""); p.panes[0].?.mode = .tty; p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; } else if (opts.shells >= 3) { // classic layout: two columns, the left one split in two. _ = 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_terms[0][0] = 0; p.col_terms[0][1] = 1; p.col_n[1] = 1; p.col_terms[1][0] = 2; } else { // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 _ = try p.newShell(0, ""); p.panes[0].?.greet = true; p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; } p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); p.panel_animation_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(); return p; } pub fn deinit(p: *Pardes) void { p.cancelLookHover(); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; for (&p.pane_alloc.fresh) |*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.fs.deinit(gpa); p.shell_rows.reset(gpa); p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); if (p.presented_cells.len > 0) gpa.free(p.presented_cells); if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs); 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 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.last_dump) |d| return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; return config.topbar_str; } /// Drop a pane: its slot is freed for reuse now, but the allocation is /// doomed and actually torn down a frame later (reapPanes), so pointers to /// it survive the frame. Callers still null `panes[id]` themselves. pub fn deinitPane(p: *Pardes, pane: *Pane) void { // Logical close, while the pane is still installed: stop the file/PDF // watch keyed to this slot and drop any hover it owns. The heap // teardown is deferred (reapPanes) so pointers to it survive the frame. const watched = (if (pane.file) |f| f.output == null else false) or hasPdf(pane); for (p.panes, 0..) |slot, id| if (slot == pane) { if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); // A pane's filesystem state dies WITH the pane, here, while the // slot still names it: the alternative is a script that held its // `event` file open leaving the editor suppressing button actions // for whatever pane lands in this slot next. p.fs.forget(p.gpa, id); }; if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); p.pane_alloc.doom(pane); } /// The real teardown, run by reapPanes once the pane has been doomed for a /// full frame (or at deinit). Frees every heap payload the pane owns. 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); term_pane.deinitPendingCommand(pane); if (pane.image) |*iv| { iv.deinit(p.image_gpa); } if (comptime pdf_enabled) if (pane.pdf) |*pv| pdf_pane.deinitPane(p, pane, pv); if (pane.file) |*file| file_pane.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); term_pane.deinitEmulator(pane, p.gpa); p.gpa.destroy(pane); } /// Once a frame: repair live panes' pointers to doomed panes, then free the /// panes doomed a full frame ago. Fixup runs first so no pointer outlives /// the memory. `stale` (last frame's dead) is freed; `fresh` becomes stale. fn reapPanes(p: *Pardes) void { for (p.panes) |slot| if (slot) |pane| p.fixupPaneRefs(pane); for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; p.pane_alloc.stale = p.pane_alloc.fresh; p.pane_alloc.fresh = @splat(null); } /// Visit each Pane field that holds a pane pointer; when it names a doomed /// pane, snapshot that pane's directory into our own bytes so the link can /// die with it. One field carries a pointer today (the inherited cwd); the /// comptime walk keeps that honest as fields come and go. fn fixupPaneRefs(p: *Pardes, pane: *Pane) void { inline for (@typeInfo(Pane).@"struct".fields) |f| { if (f.type == Pane.Cwd) switch (@field(pane, f.name)) { .inherited => |src| if (p.pane_alloc.isDoomed(src)) pane.setOwnedCwd(paneDir(src)), else => {}, }; } } /// Put a fully constructed pane in a free slot and give it the monotonic /// identity every slot-reuse guard relies on. Pane kinds construct their /// own payloads; this registration rule remains a core invariant. 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.panes[id] = pane; } /// Allocate a pane slot with a live terminal emulator and queue the spawn /// effect (optionally in a directory); the shell answers by forking a pty /// and wiring reads back as Event.output for this pane id. pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) const pane = try term_pane.create(p.gpa, p.screen_w, p.screen_h); term_pane.armShellSpawn(pane); p.installPane(id, pane); p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; } /// a doc pane (file/image/PDF): no pty and no spawn. Whatever emulator half /// it still needs is term_pane's business — see `createDoc` there. pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); const pane = try term_pane.createDoc(p.gpa, p.screen_w, p.screen_h); p.installPane(id, pane); return pane; } /// An empty output buffer opened FROM `from_id`: no file behind it, its cwd /// a live link to the opener so Save can prefill that directory. New and /// Newcol place it (below, or in a column). Installed in slot `free`. 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 output_pane.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); np.cwd = .{ .inherited = src }; 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 = p.layoutFindTerm(from_id) orelse return; const np = p.newScratch(from_id, free) catch return; p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(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 (!p.layoutCanSplitColumn(from_id)) return; _ = p.newScratch(from_id, free) catch return; std.debug.assert(p.layoutSplitColumn(from_id, free, false)); p.active = free; } pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { if (slot == null) 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; } /// `id` just performed a look: put it on top of the walk's spine. /// /// MOVE to the top rather than push, so a pane you keep looking out of /// stays one entry instead of filling the list with itself — the walk's /// order is "which panes, most recent first", not "how many times". /// Dropping the oldest when full can only ever discard a DEAD pane's /// serial: MAX_PANES entries with no duplicates already names every slot /// there is. 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; } /// Arm n/N on a concrete live pane without pretending that pane has /// already performed a Look. Result producers use this when a fresh or /// refilled list supersedes older walk history. An empty answer cannot /// supersede anything: it has no position to resume, so preserve the pane /// whose prior Look established the walk instead of stealing provenance. 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; } /// chunk arbitrary-length bytes into fixed-size write effects (order kept) pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = @min(bytes.len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } }); off += n; } } /// 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 n = @min(cwd.len, pane.cwd_buf.len); const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", }; if (cur.len == n and std.mem.eql(u8, cur, cwd[0..n])) return; if (p.lookHoverPane() == id) p.cancelLookHover(); pane.setOwnedCwd(cwd[0..n]); } /// Can a command line be typed into this pane RIGHT NOW: a terminal whose /// tty still belongs to the prompt the host forked. A terminal running vim /// answers false and is then treated exactly like a document pane — the /// command goes to some other shell (ttyForDir), because keystrokes are all /// a full-screen program would make of it. /// /// The occupancy half of that question is the host's to answer (look.ttyTaken /// walks the processes under the pane's shell pid against the tty's /// foreground process group) and it is asked HERE, lazily: only for a pane a /// command line is about to go to, and only at the moment it is about to go /// there. It used to be pushed in by every host on every frame for every /// pane, which bought nothing — nothing else in the core has ever wanted the /// answer, and a verdict one frame old is a worse one than a verdict taken /// now. The cheap half is tested first, so a pane in the wrong directory /// costs no syscalls at all. /// /// No query (web has no processes, a dump replay has no shells yet, and the /// core's own tests install their own) means every terminal is a prompt, /// which is exactly how pardes behaved before the probe existed. fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; return !p.hostTtyTaken(id); } /// What a shell should exec for the next terminal — a bare name to be /// looked up, or an absolute path to be used as it stands. The resolving /// is the shells' half: the core has no filesystem to ask. 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; } /// The native host resolved the request (or chose a fallback) for a real /// spawn. Record the executable that actually ran; web never calls this /// because it has no process backend. pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void { if (id < MAX_PANES) term_pane.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) p.settings.shell.pending = false; } /// Small backend reads over the same plain state the builtins mutate. /// Take the newest unresolved Font request once. `pending` remains true /// until the host explicitly acknowledges success or rejection; requested /// name/path remain queryable either way. 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; } /// Record the face a GUI host is currently wearing. Boot, zoom and backing /// scale changes are observations, not answers to a pending Font request: /// only acknowledgeFont may resolve one after takeFontRequest handed it to /// the host. pub fn observeFont( p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, unit: 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: 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; // The host has already replaced its face/atlas at this boundary. Even // when the new face measures to the same grid, a frozen old panel // layer cannot truthfully be rasterized through those new glyphs. 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 nextThemeFileGeneration(p: *Pardes) u32 { p.theme_file_generation +%= 1; if (p.theme_file_generation == 0) p.theme_file_generation = 1; return p.theme_file_generation; } /// Resolve and queue `ThemeFile` without doing filesystem work in the /// core. Relative paths belong to the per-user config directory; absolute /// paths remain useful for trying a file elsewhere. pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void { if (comptime !hosted) return; const input = std.mem.trim(u8, argument, " \t\r\n"); if (input.len == 0 or !std.mem.endsWith(u8, input, ".zon")) { p.reportError(id, "theme file", error.InvalidThemePath); return; } const absolute = std.fs.path.isAbsolute(input); if (!absolute and p.opts.config_dir == null) { p.reportError(id, "theme file", error.NoConfigDirectory); return; } var joined: [4095]u8 = undefined; const resolved = if (absolute) input else path: { const dir = p.opts.config_dir.?; const separator = std.Io.Dir.path.sep; const needs_separator = dir.len > 0 and dir[dir.len - 1] != separator; const len = dir.len + @intFromBool(needs_separator) + input.len; if (len > joined.len) { p.reportError(id, "theme file", error.PathTooLong); return; } @memcpy(joined[0..dir.len], dir); var at = dir.len; if (needs_separator) { joined[at] = separator; at += 1; } @memcpy(joined[at..][0..input.len], input); break :path joined[0..len]; }; if (!p.theme_file_path.set(resolved)) { p.reportError(id, "theme file", error.PathTooLong); return; } p.theme_file_pane = @intCast(@min(id, MAX_PANES - 1)); const generation = p.nextThemeFileGeneration(); p.emit(.{ .theme_file = .{ .generation = generation, .on = true } }); } pub const ThemeFileRequest = struct { path: []const u8, pane: u8, }; /// Borrow the current request while a native host performs its synchronous /// read. A later command invalidates an already-queued generation. pub fn themeFileRequest(p: *const Pardes, generation: u32) ?ThemeFileRequest { if (generation == 0 or generation != p.theme_file_generation) return null; const path = p.theme_file_path.get(); if (path.len == 0) return null; return .{ .path = path, .pane = p.theme_file_pane }; } pub fn failThemeFile(p: *Pardes, generation: u32, err: anyerror) void { const request = p.themeFileRequest(generation) orelse return; p.reportError(request.pane, "theme file", err); } /// Parse and atomically wear one exact ZON snapshot. Parse failure leaves /// the last valid custom/built-in theme untouched. `animate` is false for /// the launcher's pre-frame drain and true for an interactive load/reload. pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { const request = p.themeFileRequest(generation) orelse return false; if (bytes.len > 1024 * 1024) { p.reportError(request.pane, "theme file", error.ThemeFileTooLarge); return false; } const source = p.gpa.dupeZ(u8, bytes) catch |err| { p.reportError(request.pane, "theme file", err); return false; }; defer p.gpa.free(source); var diagnostics: std.zon.parse.Diagnostics = .{}; defer diagnostics.deinit(p.gpa); const parsed = std.zon.parse.fromSliceAlloc(Theme, p.gpa, source, &diagnostics, .{}) catch |err| { p.reportError(request.pane, "theme file", err); return false; }; if (parsed.name.len == 0 or parsed.name.len > 255) { std.zon.parse.free(p.gpa, parsed); p.reportError(request.pane, "theme file", error.InvalidThemeName); return false; } const target_chrome = ChromeTheme.fromTheme(&parsed); if (animate and p.animate_theme_changes) p.chrome_animation.retarget(target_chrome) else p.chrome_animation.snap(target_chrome); const old = p.custom_theme; p.custom_theme = parsed; p.custom_theme_active = true; if (old) |theme_value| std.zon.parse.free(p.gpa, theme_value); p.invalidateThemeDependentRasters(); _ = p.scratch.reset(.retain_capacity); p.sync(); return true; } /// Post the transient message on `id`'s last row. Called by a SHELL once /// the IO it narrates has actually happened, exactly as the shell completes /// a `save_file` effect: the core neither writes files nor owns a /// clock, so both the outcome and the wall time in `text` come from there /// (message.zig spells it, once, for both native shells). /// /// Nothing here decides WHEN it goes away — update does, on the next key or /// mouse event — and nothing here knows whether the row is free: an armed /// prompt simply outranks a message at render time, so a message posted /// under one is stored and invisible rather than refused. pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; pane.msg_len = @intCast(@min(text.len, pane.msg.len)); @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { var buf: [256]u8 = undefined; const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation; p.setMessage(id, text); } /// Apply one watched-path snapshot to the payload which owns that path. /// True means the pane now represents this successful host transaction; /// native watchers commit their generation only then, so a transient PDF /// reopen/allocation failure remains retryable. 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) { pdf_pane.reloadWatched(p, pane) catch |err| { p.reportError(id, "PDF reload", err); return false; }; return true; }; if (pane.file == null) return false; file_pane.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; } /// Synchronous host seam for a watched file. Event.update routes through /// the same payload operation, while native watchers use this spelling to /// learn whether they may commit the observed disk generation. pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool { p.invalidateLookHover(id); const applied = p.applyWatchedFileChanged(id, bytes); _ = p.scratch.reset(.retain_capacity); p.sync(); return applied; } /// Content replacement invalidates a preview whose operand was expanded /// from that pane. Payload modules call this for derived buffers they /// refresh as part of the same transaction. 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 { 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; } /// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an /// event that carries a borrowed slice cannot survive the trip, so this /// asserts rather than documents it. Hand those to `update` directly inside /// the host's borrow window instead — which is also what keeps the pty read /// path copy-free. 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, .fs_req => 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; } /// Has a program taken this pane's tty? A host that cannot tell says no, /// which is how pardes behaved before the probe existed. fn hostTtyTaken(p: *const Pardes, id: usize) bool { const f = p.host.vtable.pull_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.push_write_file) |f| return f(p.host.ctx, pane, path, bytes); p.fallback.writeFile(path, bytes); } /// 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(); } /// THE BYTES BEHIND AN `.fs_reply`, resolved in the drain. A filesystem /// read answers with either something the handler formatted (staged in the /// core, valid until the next request) or a window onto a pane's live text, /// which is handed over WITHOUT A COPY — the same trick, and the same /// serial check, `.save_text` uses to write a megabyte it never duplicated. /// A slot reused between the answer and this call resolves to nothing /// rather than to another pane's text. pub fn fsPayload(p: *const Pardes, r: acmefs.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)]; }, }; } /// Perform one effect through the host, falling back per METHOD (not per /// host) to the in-process implementation. This is the switch that used to /// be copied into all four shells. pub fn perform(p: *Pardes, e: Effect) void { const v = p.host.vtable; switch (e) { .spawn => |s| if (v.push_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.push_pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), .resize_pty => |r| if (v.push_pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), .open_link => |u| if (v.push_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 = pane.file orelse return; p.hostWriteFile(sf.pane, f.path, f.content); }, .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 = term_pane.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.push_write_dump) |f| { f(p.host.ctx, out); } else { // A real host reports where it landed, which is what puts // `Restore ` in the topbar; the virtual one owes the // same, or the bytes it holds are unreachable. p.fallback.writeFile(fallback_dump_path, out); p.setLastDump(fallback_dump_path); } }, .set_clipboard => { const text = p.yank orelse ""; if (v.push_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.pull_read_clipboard) |f| f(p.host.ctx) else p.update(.{ .paste = p.fallback.clipboard.items }), .lsp => |q| if (v.pull_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.pull_pipe) |f| f(p.host.ctx, q.id) else p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), .watch => |w| if (v.push_watch_file) |f| f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on) else { p.fallback.watched[w.pane] = w.on; }, .theme_file => |t| if (v.push_watch_theme) |f| f(p.host.ctx, t.generation, t.on), .dump_themes => |d| if (v.push_dump_themes) |f| f(p.host.ctx, d.pane), // The bytes are read off the core HERE, in the drain, exactly as // save_file reads a file pane: the reply named where they live and // this is the borrow window. A host with no filesystem serving // cannot have asked, so a null method is not a dropped answer. .fs_reply => |r| if (v.push_fs_reply) |f| f(p.host.ctx, &r, p.fsPayload(r)), // the loop's own condition; a host tears down after its own loop .quit => p.quit = true, } } /// ONE ITERATION OF THE LOOP, and the reason the core owns it: the ORDER /// here — wait, apply input, perform effects, poll, render, present — was /// copied into four shells and drifted in each. A host supplies the parts /// only it can (blocking, pixels, processes) and nothing else. /// /// It is one PUMP and never a `while`: no host gives up its outer loop. /// AppKit owns NSApplication's run loop, the browser owns the frame /// callback, and both Linux hosts keep a thin one so Restore can swap the /// whole core between frames. pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; if (v.pull_wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); // A quitting frame has already freed what it would draw. if (p.quit) return; if (v.push_poll_frame) |f| f(h.ctx); _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.push_present) |f| f(h.ctx, surface); if (v.push_post_present) |f| f(h.ctx); // Animation TIME is not spent here. `wait_input` was told how long it // may sleep; a display clock wakes faster than that on input, so only // the host knows when a real frame interval has passed. Each spends it // by handing back one `.tick`. } pub fn update(p: *Pardes, ev: Event) void { // Free a motion surface that went bad during the LAST update, before // anything in this one can ask for it. Nothing frees it mid-update: // handlers hand `rows` around for the length of a single update. p.shell_rows.sweep(p.gpa); // A preview describes the frame under an idle pointer. Any state // change can replace that text or geometry, so it cancels; buttonless // motion is the one event that debounces/re-arms it, and ticks only // age the current candidate. 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(); }, } // A transient message is exactly as old as your last input: touch the // keyboard or the mouse and it is gone, on every pane, because a // message is a report and you have just proved you are back. Only // INPUT counts — a resize, pty output, a watch or an answering worker // all repaint without you, and a message that a background shell could // wipe would be one you never got to read. // // An ARMED PROMPT is a different occupant of the same row and is not // touched here: it lives in tag_tail, it is what the keystroke is being // typed INTO, and it ends at Enter or Esc. So clearing here can never // fight one — at worst it clears something the prompt was already // hiding. switch (ev) { .key, .mouse => { for (p.panes) |slot| { if (slot) |pane| pane.msg_len = 0; } // ...and the same reasoning bounds a clipboard read in flight. // A terminal that gates or refuses the OSC 52 request never // answers at all, so the request cannot be allowed to sit and // then fire minutes later into whatever pane is focused by // then: it lives exactly until your next keystroke, and a // shell that answers within one round trip (every one but a // refusing tty) is unaffected. p.clip_pending = null; }, else => {}, } // Which input this update IS, in acme's origin alphabet, so every // event record the handlers below produce is attributed without any // of them being told: `K` for the keyboard, `M` for the mouse. A // filesystem write says `E`/`F` for itself (see acmefs). if (p.fs.listeners != 0) p.fs.origin = switch (ev) { .key => 'K', .mouse => 'M', else => p.fs.origin, }; switch (ev) { .resize => |sz| { p.snap_panel_layout_once = true; if (comptime pdf_enabled) { var before: [MAX_PANES]?pdf_pane.Viewport = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf != null) before[id] = pdf_pane.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); // Compare the effective per-pane pixel viewport, not the // resize event itself: duplicate SIGWINCH notifications // must not undo a reader's manual pan. p.computeGeom(); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf) |*pv| { if (!std.meta.eql(before[id], pdf_pane.paneViewport(p, pane))) { pdf_pane.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; term_pane.feedOutput(p, pane, o.bytes); }, .eof => |e| p.removePane(e.pane), .lsp_resp => |r| p.lspResponse(r.id, r.rows), .pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs), .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| { // Layout is only committed on RELEASE. Mark that one frame as // a direct-manipulation snap before dragRelease mutates the // weights; text/tag/PDF drags never touch panel presentation. if (m.kind == .release) switch (p.drag) { .border_v, .border_h, .move => p.snap_panel_layout_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 (hasPdf(pane) and p.native_images) _ = pdf_pane.scrollPane(p, pane, @floatCast(scroll.delta_pixels)); } } } }, .paste => |bytes| p.applyPaste(bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), // One filesystem request in, one answer out, in this update. The // whole of the concurrency is that the transport asked from the // loop thread; see acmefs.zig's header. .fs_req => |r| p.emit(.{ .fs_reply = acmefs.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .pointer_leave => p.pointer_inside = false, .tick => { p.chrome_animation.advance(); p.advancePanelAnimations(); p.advanceLookHover(); }, } _ = p.scratch.reset(.retain_capacity); p.sync(); p.fsReport(); } /// TAG EDITS, which no single call site owns: a tag is assembled from a /// live prefix and an editable tail by half a dozen paths (typing, a prompt /// arming, a Save clearing the dirty marker, a shell reporting a new cwd), /// so it is diffed at the END of an update, where it is finally settled. /// acme can hook `textinsert` on the tag itself because its tag IS a text /// buffer; pardes's is a rendering, so the diff is the honest equivalent. /// /// Costs nothing when nobody is listening: one branch, and the snapshots /// are only allocated for panes a script has opened. fn fsReport(p: *Pardes) void { if (p.fs.listeners == 0) return; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (!p.fs.scripted(id)) continue; const tag = p.tagText(p.scratch.allocator(), pane) catch continue; const snap = &p.fs.panes[id].tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; // First sight of a tag is not an edit: the script just opened the // file and can read `tag` for itself. `release` drops the snapshot // with the last reader, so this stays true across re-opens. if (snap.capacity != 0 or snap.items.len != 0) acmefs.noteReplace(p, id, true, snap.items, tag); snap.clearRetainingCapacity(); snap.appendSlice(p.gpa, tag) catch {}; } } /// THE DEFAULT REGISTER, and nothing else. helix: an ordinary `y`/`d`/`c` /// writes here and the system clipboard never hears about it — which is /// also the bug this spelling fixes, because a mirror on every write made /// deleting one character clobber whatever the desktop was holding. /// `SPC y` is the command that crosses over (setClipboard below). fn setYank(p: *Pardes, text: []const u8) void { // Inside a multi-selection replay the register collects EVERY range's // text, in document order — the passes run last-range-first, so each // new piece goes in front of what is already there. // ponytail: helix keeps one register VALUE per range and pastes // value[i] back at range[i]; pardes has a single register, so N // cursors yank one newline-joined blob and a paste puts that whole // blob at every cursor. Written down as a differential waiver rather // than faked — a per-range register is its own feature. if (p.multi_on and !p.multi_first) { const old = p.yank orelse ""; const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n"; const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return; if (p.yank) |y| p.gpa.free(y); p.yank = joined; return; } if (p.yank) |y| p.gpa.free(y); p.yank = p.gpa.dupe(u8, text) catch null; } /// ...and the register PLUS the system clipboard, which is the whole /// difference between `y` and `SPC y`. /// /// One mirror per KEYSTROKE rather than per cursor: a multi-selection /// replay runs last-range-first and `multi_first` marks its first pass, so /// emitting there queues exactly one effect — and the shell reads /// `core.yank` when it DRAINS, by which time every later pass has folded /// its range in. That asymmetry used to be a silent hole: the old mirror /// sat past the join's early return, so a multi-cursor yank reached the /// clipboard on one cursor and not on two. fn setClipboard(p: *Pardes, text: []const u8) void { p.setYank(text); if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} }); } /// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers. pub const ClipRequest = struct { pane: usize, /// the pane's identity, not its slot: the answer can arrive whole /// keystrokes later (a tty's OSC 52 round trip) and a freed slot is /// reused by an unrelated pane. serial: u32, mode: enum { after, before, replace }, }; /// `SPC p` / `SPC P` / `SPC R`: ask the shell for the system clipboard and /// remember what to do with it. The request is deliberately fire-and-hope /// — a terminal that refuses the OSC 52 read simply never answers, and the /// next keystroke drops the request (see update) rather than letting a /// paste land minutes late in whatever pane is focused by then. pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) void { const pane = p.panes[id] orelse return; p.clip_pending = .{ .pane = id, .serial = pane.serial, .mode = mode }; p.emit(.read_clipboard); } /// Type text at a pane's program, the way a terminal emulator pastes: /// bracketed when the app set mode 2004 (readline/vim/helix strip the /// markers and refuse to run what arrives), else with `\n` turned to `\r`, /// because a raw newline in an unbracketed paste IS the Enter key and a /// multi-line paste would run every line but the last. pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { if (text.len == 0) return; if (term_pane.bracketedPaste(pane)) { p.emitWrite(id, "\x1b[200~"); p.emitWrite(id, text); p.emitWrite(id, "\x1b[201~"); return; } const cp = p.scratch.allocator().dupe(u8, text) catch return; for (cp) |*ch| { if (ch.* == '\n') ch.* = '\r'; } p.emitWrite(id, cp); } /// The shell answered with system-clipboard text — or the desktop pasted /// into us unasked. Either way the bytes are pasted WITHOUT going through /// the register: helix's clipboard commands and the default register are /// separate stores, and a paste that quietly overwrote your `y` would be /// the same clobbering bug in the other direction. fn applyPaste(p: *Pardes, bytes: []const u8) void { const req = p.clip_pending; p.clip_pending = null; if (bytes.len == 0) return; const id = if (req) |r| r.pane else p.active; const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; // A pane in tty mode has no editable buffer to paste INTO — the pty // owns its screen. Type the bytes at the program instead, which is // also what makes a desktop paste (Ctrl-Shift-V, middle click, the // window manager's own) reach a shell at all. if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { .after => p.pasteText(pane, bytes, false), .before => p.pasteText(pane, bytes, true), .replace => p.replaceWithText(pane, bytes), } } /// `SPC y` / `SPC Y`: the selection to the system clipboard. `main_only` /// is helix's capital — every cursor's text joined, versus the primary /// selection's alone. A PDF has no editable buffer to replay over, so its /// own selection answers directly. pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { if (comptime pdf_enabled) if (pane.pdf) |pv| { if (pv.selection_text.len > 0) p.setClipboard(pv.selection_text); return; }; // the ordinary `y` path, so what reaches the clipboard is exactly what // the key would have put in the register — including the multi-cursor // join, which is replaySels' business and not a second implementation if (pane.nsel > 0 and !main_only) { p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { const others = pane.nsel; pane.nsel = 0; p.normalYank(pane); pane.nsel = others; } p.emit(.{ .set_clipboard = {} }); } /// A per-KEYSTROKE action reached from inside a per-SELECTION replay — /// one that opens, closes or focuses a pane, or asks the language backend. /// It must happen once rather than once per cursor, and once it has, the /// remaining passes are meaningless (the pane they would edit may be gone), /// so the replay stops. True = this pass may go ahead. fn multiOnce(p: *Pardes) bool { if (!p.multi_on) return true; p.multi_stop = true; return p.multi_first; } // ---- tag + selection text (chord sources) ---- /// the live tag prefix: the pane's cwd/path, plus pane-local state whose /// owner reports it (PDF view and image renderer choices). Those choices /// mutate only through builtins under SPC t. The mode used to lead this as a word; it is /// the one character in the layout box now (renderPane), so every tagline /// starts four columns further left and spends them on the path instead. /// /// Arena-allocated like everything the renderer is handed — the tag is /// retained through the frame and a cwd can be rewritten under us by the /// next shell report, so this owns its bytes rather than lending the /// pane's. 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, "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}", .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path }, ); if (pane.image) |*state| return image_pane.tagPrefix(arena, state); if (pane.file) |f| { if (output_pane.fileTraits(f.output).saves and f.revision != f.saved_revision) return std.fmt.allocPrint(arena, "{s}{s}", .{ f.path, dirty_marker }); return arena.dupe(u8, f.path); } 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); } /// The untouched command tail for this pane class. `curTail` and tagGap /// must ask the same question: otherwise a terminal renders Filter but /// still votes for the shorter generic tail when a column is aligned. /// /// Save leads wherever the pane holds text of its own — every pane with a /// file, an output buffer included, plus every terminal. What is left is an /// image and a PDF: their bytes on disk already are what they are. fn defaultTail(pane: *const Pane) []const u8 { 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; } /// Spaces to sit between the path and the commands, so the commands END /// `tag_right_pad` columns short of the pane's edge — right-aligned, with /// that many columns left free to type in. /// /// Real spaces rather than a second print at an offset, because the tag is /// ONE buffer that tag_col, the mouse, the motions and the chord all index /// by the same columns; two separately-positioned pieces would need a /// column-to-offset map that none of them has. Being characters is also /// what makes both paddings editable, which is the point: `:` lands at the /// start of this gap, and there are `tag_right_pad` free columns past the /// commands to type into. /// /// Zero once the tag has been touched. From then on the spaces are IN /// tag_tail and belong to you — recomputing would both double them and /// slide the commands sideways under your cursor as you type. So an /// untouched tag reflows with the pane and an edited one stays put. /// /// The end column is shared by the whole LAYOUT COLUMN — every DRAWN pane /// at the same x and width, which is exactly the set whose taglines sit /// above one another on screen (`h == 0` is a pane squeezed off the bottom /// by a shrunk window: renderPane returns early on it, so it has no words /// to line up with and gets no vote). `tag_right_pad` sets the end, but a /// path too long to fit inside that pad used to collapse only ITS pane's /// gap to zero, which left one row's commands jammed against the path while /// the row below kept its out at the pad. Now the column moves out /// together, so the words stay in a line and a click walks down them. /// /// Alignment is measured in display cells. Cursor and selection state use /// UTF-8 byte offsets, then map through the same grapheme-width helpers at /// the screen boundary. /// /// Two rules make up the "when possible": /// /// - a voter that does not FIT is counted at the pane's right edge rather /// than dropped. Dropping it would leave the rest of the column its /// typing room, but it is a threshold: one column of resize either side /// of the fit moves every tagline in the column by the whole pad, and /// dragging a window edge across that width snaps the words back and /// forth under the pointer. Clamping buys a crossing one column wide /// and monotone, and the price is a column of taglines that can end /// hard against the right edge with nothing left over to type in. /// - a TOUCHED tag votes with the end it was FROZEN at, not with its live /// tail: the gap is baked into tag_tail as leading spaces, and counting /// what you type after the builtins would drag the column sideways on /// every keystroke. It still takes no gap of its own (above) — but it /// has to keep voting, or clicking the widest tagline in a column would /// snap every other one left, out from under the next click. /// /// ponytail: every voter's prefix is FORMATTED to be measured, so a frame /// costs up to MAX_PANES² path dupes — 256 bump allocations into the /// scratch arena that renderPane resets anyway, at a realistic two to four /// panes. The alternative was a second tagPrefix that only counted, and /// keeping two spellings of one string in step by comment is the more /// expensive kind of cost. fn tagGap(p: *Pardes, pane: *const Pane, used: usize) usize { if (pane.tag_init) return 0; const id = p.paneIdOf(pane) orelse return 0; const r = p.rects[id]; if (r.w <= config.GUTTER) return 0; const tw: usize = r.w - config.GUTTER; var end: usize = tw -| config.tag_right_pad; for (p.panes, 0..) |slot, qid| if (slot) |q| { const qr = p.rects[qid]; if (qr.h == 0 or qr.x != r.x or qr.w != r.w) continue; // prefix ++ the spaces in front of the words ++ the words: the // frozen gap for a touched tail, the default's own single leading // space for an untouched one (which is why there is no +1 here) const words = defaultTail(q); const laid = if (q.tag_init) q.tagSlice() else words; const lead = laid.len - std.mem.trimStart(u8, laid, " ").len; const q_end = file_pane.displayWidth(p.tagPrefix(q) catch continue) + lead + file_pane.displayWidth(std.mem.trimStart(u8, words, " ")); end = @max(end, @min(q_end, tw)); }; return end -| used; } /// the tag exactly as it is rendered: prefix ++ gap ++ tail. THE text /// tag_col and tag_anchor index, so the renderer, the mouse, the motions /// and the chord all read the same bytes at the same columns. pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail)); const out = try arena.alloc(u8, prefix.len + gap + tail.len); @memcpy(out[0..prefix.len], prefix); @memset(out[prefix.len..][0..gap], ' '); @memcpy(out[prefix.len + gap ..], tail); return out; } /// Take the laid-out tail into the pane's own buffer, once, on first touch. /// The gap comes along as ordinary characters — that is what hands the /// padding to you to edit, and what freezes it against reflow from here on. pub fn seedTail(p: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); const prefix = (p.tagPrefix(pane) catch return); const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail)); if (gap + tail.len > pane.tag_tail.len) return; @memset(pane.tag_tail[0..gap], ' '); @memcpy(pane.tag_tail[gap..][0..tail.len], tail); pane.tag_tail_len = gap + tail.len; pane.tag_init = true; } /// focus the tag for editing, seeding the tail on first touch and parking /// the byte cursor at the grapheme displayed under screen column `col`. /// NEGATIVE means the tail's first WORD, which is where `:` and a tagline /// hop land: a place no click can name, so it needs no sentinel of its own /// and the callers need no prefix length. fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { 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.pending = 0; const end = edit0 + @as(i32, @intCast(pane.tag_tail_len)); // Past the gap that right-aligns the builtins, not at the first // editable column: `:` promises the tail's START, and the start of a // run of layout spaces is not it. Landing there would cost `:w` its // second keystroke — w would select the whitespace and execute nothing // — and `:w` being the same two keys every time is the whole point of // the door. The spaces stay editable; h and Left still walk into them. const tail = pane.tagSlice(); const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); if (col < 0) { pane.tag_col = @intCast(@min(edit0 + lead, end)); } else { const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(col)))); } } fn exitTagEdit(pane: *Pane) void { // tags are always insert; leaving one restores the body mode: files // back to normal, terminals to whatever they had — a click (this runs // on every body press) must never change a shell pane's mode 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.prompt = .none; // an abandoned prompt stays as tag text pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } 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) }; } /// the text a tag Enter/Tab chord (and `y`) acts on: the char selection, /// else the file-ish word under the cursor. Over the WHOLE rendered tag, so /// the path is a word like any other — Enter on it looks it. 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; } /// tag-edit key dispatch: a modal one-line editor over the rendered tag, /// sharing the pane's mode — the cursor moves over all of it, edits reach /// only the tail. Newlines are always dropped. 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 (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; } // the prefix is live chrome, not text you own: the cursor may sit in it // (that is how the path selects), but every edit below is measured from // the first EDITABLE column and simply does nothing to the left of it. const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail_len)); if (key.text.len > 0) { if (pane.tag_col < edit0) return; // typing inside the path: inert var insert_len: usize = 0; for (key.text) |ch| insert_len += @intFromBool(ch != '\n' and ch != '\r'); if (insert_len > pane.tag_tail.len - pane.tag_tail_len) return; for (key.text) |ch| { if (ch == '\n' or ch == '\r') continue; // one-line tag if (!pane.insertTagByte(pane.tag_col - edit0, ch)) return; pane.tag_col += 1; } return; } switch (key.cp) { Key.backspace => if (pane.tag_col > edit0) { const text = p.tagText(p.scratch.allocator(), pane) catch return; const prev = @max(@as(usize, edit0), modal.prevGrapheme(text, pane.tag_col)); const count = @as(usize, pane.tag_col) - prev; for (0..count) |_| pane.removeTagByte(prev - edit0); pane.tag_col = @intCast(prev); }, Key.left => if (pane.tag_col > 0) { const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col)); }, Key.right => if (pane.tag_col < end) { const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col)); }, Key.home => pane.tag_col = 0, Key.end => pane.tag_col = 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.HxRange) void { const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); pane.tag_col = @intCast(modal.hxCursor(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)); } /// normal mode ON the tag — where `:` lands. The body's own helix motions /// with the WHOLE rendered tag as a one-line document (so the path selects /// like any other text, and Enter on it looks it), plus insert entry and /// the acme chords; editing keys stay in insert (`i` then type, like the /// mouse path) and never reach left of `edit0`. 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)) return exitTagEdit(pane); // the chord: run the selection (or the word under the cursor) and drop // back into the body — the whole point of `:`. The same two bindings as // everywhere else (config.look_key / exec_key), so the command line is // `:w` by default. // Nothing under the cursor means nothing ran: the tag keeps focus. 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; exitTagEdit(pane); // leave the tag FIRST: Del frees the pane p.runBuiltin(cmd, id, "", txt); return; } // y — yank what the chord would run: the selection, else the word under // the cursor. The path is selectable, so this is how you copy it out. // // The ONE register write that still mirrors to the system clipboard // without `SPC` in front of it, and it is not an exception so much as // the only spelling available: a tag is always in insert mode, the // leader is body-normal only, so `SPC y` cannot be pressed here — and // "copy this path somewhere else" is the entire reason the chord // exists. A path that only reached the internal register would be a // key that does nothing you can observe. if (hit(key, config.tag_yank)) { if (p.tagChordText(pane)) |txt| p.setClipboard(txt); return; } // insert entry (one line, so I/A are the tail's ends). The prefix is // read-only, so entering insert inside it parks at the first editable // column instead — you can never be typing into the path. 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 = (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; } // h/j/k/l — a tagline is a place in the LAYOUT, so the four letters walk // it: focus the neighbour and land on ITS tagline, still in normal mode, // so tag-to-tag navigation never drops through a body. Runs the SAME // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run, off the // SAME table (config.window_keys) — one focusDir, one binding. Only the // LETTER column: the arrows keep the in-tag grapheme motion below, so // the letters cost nothing. Nothing in that direction = stay put, tag // and all: focusDir left `active` alone, so there is nothing to undo — // EXCEPT upwards, where "nothing" is still something (see below). const dir: ?Builtin = for (config.window_keys) |wk| { if (hit(key, &.{wk.letter})) break wk.cmd; } else null; if (dir) |d| { const from = p.active; p.runBuiltin(d, from, "", null); if (p.active == from) { // above the topmost tagline is the TOPBAR — row 0, the global // one. It is not a pane, so focusDir can never reach it; this // one fallback is what makes `k` walk off the top of the // layout instead of dying there. Only from a tagline: a body's // `SPC w k`/`Ctrl-w k` keeps its pane-to-pane meaning. if (d == .Up) { exitTagEdit(pane); p.topbar_col = 0; } 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); // ...and the tail's start is the new park if (dest.tag_edit) dest.mode = .normal; return; } if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); } /// the one-line normal-mode motion vocabulary as a helix range over `text`, /// or null when `key` is not one of them: arrows by grapheme, `0`/`$`/`^` /// (Home/End) to the ends, and the word motions, which select the span they /// traverse exactly like the body's. A pane's tag and the TOPBAR are the /// same one-line normal mode over different bytes, so the keys are read in /// one place; only `h`/`l` differ — a tagline spends them on the layout, /// the topbar has no layout — and each caller answers those itself. fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.HxRange { 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.hxWordMove(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); } /// normal mode ON the topbar — row 0, where `k` off a top-row tagline /// lands. The same one-line vocabulary a tag has (lineMotion), plus `h`/`l` /// as plain grapheme motion: up here they have no neighbouring window to /// walk to, so they cost nothing. Enter/Tab runs the word under the cursor /// through the very dispatch a middle click on it uses, and `j` drops back /// onto a tagline. The bar is chrome with no tail of its own, so there is /// deliberately no insert mode and no selection: focus, move, run, leave. fn topbarKey(p: *Pardes, key: Key) void { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); // 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)) { p.topbar_col = null; // the active pane still has its body focus return; } // the chord: run the word under the cursor, exactly as a middle click // on it does. Leave the bar FIRST — `Kill` lives up here and tears the // session down, the same hazard the pane-tag chord has with `Del`. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const word = wordAtCol(bar, cur); p.topbar_col = null; if (word.len > 0) _ = p.execute(p.active, word); return; } // j — back down onto a tagline, the mirror of the k that got you here: // the pane you came from if it still holds the top row, else the // leftmost pane that does. Recomputed, never remembered, so a pane // deleted while the bar had focus strands nobody. if (hit(key, config.topbar_down)) { var dest: ?usize = null; for (p.panes, 0..) |slot, i| { if (slot == null or p.rects[i].y != TOPBAR_H) 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.topbar_col = null; p.active = d; p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; 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.hxCursor(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))); } /// tag + '\n' + body: the full selectable pane text (row 0 = the tag) fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try p.bodyText(arena, pane) }); } /// block-selected text, newline-joined per row; reads the rendered screen /// so typed text and shell output select identically. Scratch-owned. fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 { const arena = p.scratch.allocator(); const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1))); const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1))); const text = try p.paneText(pane); var total: usize = 0; var selected: usize = 0; var count_it = std.mem.splitAny(u8, text, "\n"); var count_row: i32 = 0; while (count_it.next()) |line| : (count_row += 1) { if (count_row < r0 or count_row > r1) continue; const b0 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c0), line.len); const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, count_row, line, c1), line.len)); total += b1 - b0 + @intFromBool(selected > 0); selected += 1; } const out = try arena.alloc(u8, total); var at: usize = 0; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; var first = true; while (it.next()) |line| : (v += 1) { if (v < r0 or v > r1) continue; if (!first) { out[at] = '\n'; at += 1; } first = false; const b0 = @min(file_pane.renderedLineByteCol(pane, v, line, c0), line.len); const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, v, line, c1), line.len)); @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]); at += b1 - b0; } return out; } /// Is (r,c) inside the span (ar,ac)..(br,bc), in reading order? Either end /// may be given first — a selection swept upwards has its anchor after its /// head — and the coordinate SYSTEM is the caller's: screen cells for a /// mouse selection, absolute rows for a modal one. fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool { const fwd = ar < br or (ar == br and ac <= bc); const sr, const sc = if (fwd) .{ ar, ac } else .{ br, bc }; const er, const ec = if (fwd) .{ br, bc } else .{ ar, ac }; return (r > sr or (r == sr and c >= sc)) and (r < er or (r == er and c <= ec)); } const ExpandedWord = struct { lo: usize, hi: usize }; /// The single spelling rule used by pointer and keyboard expansion. /// Spaces inside an explicit @`command run` belong to that run; every /// other separator is no operand. In particular, do not let wordBounds' /// left scan make a blank cell borrow its neighbour. 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: Sel) ?Sel { var sl = at; if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; const text = p.paneText(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 = file_pane.renderedLineByteCol(pane, v, line, display_col); const b = expandedWord(line, col) orelse return null; sl.c0 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.lo)); sl.c1 = @intCast(file_pane.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) 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) file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else blk: { const pl = p.paneCursorLines(pane) catch break :blk pane.cur_col; const local = pane.cur_row - pl.row0; if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len) break :blk pane.cur_col; break :blk file_pane.lineDisplayOffset(pl.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 }; } /// the active modal selection as text (v range or x lines), if any fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { if (comptime pdf_enabled) if (p.native_images) if (pane.pdf) |pv| if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text; if (pane.vsel.active) return p.vselText(pane); if (!pane.msel.active) return null; return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } 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: ?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: ?Sel = null, }; /// Resolve a no-drag pointer gesture without mutating Pane. Click release /// and delayed Look hover call this same production primitive, so existing /// selection precedence and word expansion cannot drift into two policies. fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Sel) PointerOperand { const visible = clicked.r0 - @as(i32, BOX_H); const wrapped = pane.wrapAt(visible); const row = wrapped.line; const col = if (clicked.r0 >= BOX_H) p.paneByteAtDisplay( pane, wrapped.line, wrapped.at, clicked.c0 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0), ) else clicked.c0; var result: PointerOperand = .{ .row = row, .col = col }; const kept = pane.sel[sel_slot]; if (kept.state == .done and spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1)) { result.text = p.selectionText(pane, kept) catch null; result.preview = kept; return result; } if (pane.vsel.active and pane.vsel.explicit and spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col)) { result.text = p.currentSelText(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 = p.currentSelText(pane); return result; } if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { const line = file_pane.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 }; // The transient middle/right slot still carries screen endpoints, // but no text is reconstructed from this rectangular legacy // shape. The logical span above is authoritative for dispatch and // hover painting. 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, config.PREFIX_W) + file_pane.displayOffset(pane, row, first.at, lo); expanded.r1 = last.row + @as(i32, BOX_H); expanded.c1 = @as(i32, config.PREFIX_W) + file_pane.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 = p.selectionText(pane, expanded) catch null; if (result.text == null or result.text.?.len == 0) return result; result.preview = expanded; result.expanded = expanded; return result; } /// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme — /// the chord argument is whatever is selected, in any window), searched /// `first` (the pane the execute happened in), then the active pane (making /// a selection focuses its pane, so it holds the most recent one), then /// slot order. Per pane a kept left selection wins, else an explicit modal /// (v/x, n/N) one. Scratch-owned: dead at the next arena reset, so a caller /// that keeps it (the 2-1 chord) copies. 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 (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx; } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { if (p.currentSelText(t)) |tx| return tx; } } return null; } /// Splice a chord argument onto what the gesture pointed at. acme's 2-1 /// chord means "run this WITH that", and that is a command LINE: `Grep` /// plus a held `foo` is the same string `Grep foo` you could have typed, /// so it goes down the one path that already knows how to split a name /// from its tail. The alternative — a second argument threaded past the /// dispatcher — is what used to be here, and it could not survive Exec /// becoming an ordinary builtin with one argument slot like every other. /// Scratch-owned; `txt` itself when there is nothing to splice. 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; } fn handleKey(p: *Pardes, key: Key) void { // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is // its own one-line normal mode and owns every key until Esc or a chord. // Before the pane lookup because it needs no pane — that is the point. if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; // a SPC leader in flight swallows the next key, whatever it is — // before Ctrl-w, so a modified key abandons the sequence instead of // arming a second prefix on top of it if (p.leader_on) return p.leaderKey(key); // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l or the // arrows), running the SAME builtins `SPC w h/j/k/l` runs off the SAME // table a tagline's own h/j/k/l reads. It stays despite the leader // covering it because it reaches one place the leader cannot: a pane // in raw tty mode never sees SPC (the shell owns every printable key), // so this is the only keyboard way out of one. 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 keeps normal/tty 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 = p.splitParent(p.active); const f = p.layoutFindTerm(src).?; p.layoutInsert(f.col, f.idx + 1, free); p.splitBelow(src, nt); p.active = free; return; } // the jump chords, global for the same reason: Ctrl-o has to get you // out of wherever you are, including a pane in raw tty mode. Above the // acme chords below, which is what makes Ctrl-i reachable at all — on // a kitty-protocol host it arrives as its own key, and where it does // not it IS Tab (0x09) and falls through to Exec, see config.jump_keys. 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 = p.layoutFindTerm(p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { _ = p.layoutSplitColumn(p.active, p.active, false); } return; } // the configured Ctrl-key, or Shift-Esc, toggles raw tty mode in and // out (terminals only); tty is deliberately off the normal editing // path. Shift-Esc needs a host that reports modifiers on Escape (the // kitty keyboard protocol); where it doesn't it arrives as a plain // Escape and still means what Escape always means. 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) { if (pane.mode == .tty) { // Shift-Esc IN tty is what Escape is in normal mode: Last, // the pane you were in before this one. The pane keeps its tty // mode, so coming back lands you in the program you left rather // than in normal mode on top of it — and Ctrl- is still how you leave tty in place. if (tty_alt) return p.runBuiltin(.Last, p.active, "", null); pane.mode = .normal; pane.pending = 0; } else term_pane.enterTty(p, p.active); return; } // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. if (pane.isTerminal() and pane.mode == .tty and hit(key, config.escape) and p.takesCommandLine(p.active)) return p.runBuiltin(.Last, p.active, "", null); if (pane.isTerminal() and pane.mode == .tty) { // Clipboard first: `hit` ignores a shift no binding asked for, so // the plain Ctrl-V below would otherwise swallow Ctrl-Shift-V. if (hit(key, config.tty_paste_clipboard)) return p.clipRequest(p.active, .after); if (hit(key, config.tty_paste)) return p.typeToTty(p.active, pane, p.yank orelse return); } // `|` owns the same visible one-line tag input as search, but Enter // snapshots an asynchronous shell filter. Escape is a pure cancel: // restore the old tail and never emit a request. if (pane.hasPipePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { const prompt_at = pane.promptAt().?; if (hit(key, config.search_submit)) p.submitPipe(p.active); pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; return; } // a save input in flight (scratch or terminal): Enter writes the path, // Esc abandons; both drop the prompt text and return to the body. if (pane.hasSavePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { const prompt_at = pane.promptAt().?; if (hit(key, config.search_submit)) p.submitSave(p.active) else pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; return; } // a search input in flight (`/` or Find): Enter searches, Esc abandons; // both restore the tag tail and hand focus back to the body. if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { const prompt_at = pane.promptAt().?; if (hit(key, config.search_submit)) p.submitSearch(p.active) else if (selRegexArmed(pane)) |_| p.applySelRegex(pane, "", false); pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; return; } // tag editing intercepts every other key: a modal one-line editor over // the tail, sharing the pane's mode. Above the body chords — a focused // tag owns Enter/Tab too (that IS the `:` command line). if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); // an armed `s`/`S` re-runs its pattern after EVERY keystroke: that // live preview is what makes it interactive. Through the slot, not // `pane` — a tag chord above can run a builtin that closed it. const pn = p.panes[p.active] orelse return; if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); return; } // the acme chords in the body — look at / execute (config.look_key and // exec_key, Enter and Tab by default) the EXPLICIT modal selection // (v/x/X, terminal n/N, search n/N); implicit motion residue falls back // to the file-ish word under the cursor. 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; p.pinPaneCursor(pane); const explicit = (p.native_images and hasPdfSelection(pane)) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { if (p.currentSelText(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 = p.selectionText(pane, sel) catch return; p.runBuiltin(cmd, p.active, "", word); return; } // PDFs share BODY-NORMAL recognition with text, then deliberately // adapt only navigation and the cross-pane command/search actions. // Returning here keeps unsupported edits away from placeholder cells. if (pane.mode == .normal and hasPdf(pane)) return p.handlePdfNormal(pane, key); switch (pane.mode) { .normal => { p.handleNormal(pane, key); }, .insert => { if (hit(key, config.escape)) { pane.mode = .normal; pane.msel.active = false; pane.pending = 0; // leaving an `a` append session: the cursor backs up one // grapheme and the appended-over span becomes the // implicit selection (helix doc.restore_cursor) if (pane.append_at) |aa| { pane.append_at = null; const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); // helix restore_cursor is Range::new(from, prev(to)) on // the GAP range, so it can never walk back past the // append origin — a session whose edits ate everything // typed collapses ONTO it. Without the clamp the cursor // lands one cell before where the append began (`la`, // Backspace, Esc). const back = @max(a_off, modal.prevGrapheme(text, gap)); const bc = modal.hxPos(text, back); pane.cur_row = @intCast(bc.row); pane.cur_col = @intCast(bc.col); pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; pane.cur_pinned = true; pane.ensureCursorVisible(); // The other cursors move the same way the primary did — // every one of them was handed the same keys, so every // session shrank by the same grapheme — but they // COLLAPSE rather than span: only the primary remembers // where its append began (append_at is one field). // ponytail: helix restores every range's appended-over // span; store an origin per SelRange if that matters. for (pane.sels[0..pane.nsel]) |*s| { const sgap = modal.hxOff(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); const bp = modal.hxPos(text, b2); s.row = @intCast(bp.row); s.col = @intCast(bp.col); s.arow = s.row; s.acol = s.col; } } return; } p.handleInsert(pane, key); }, .tty => term_pane.forwardKey(p, p.active, key), } } /// A key after SPC: walk the leader tree (leader_rows, the comptime table). /// `?` at any depth opens Help scoped to the path typed so far; an exact /// path runs its builtin with no arguments; a key that only extends a /// group keeps waiting. ANYTHING else abandons the sequence — a typo must /// not leave the next keystroke armed at a builtin that closes panes, and /// the indicator vanishing is the receipt (vim and helix drop unmapped /// leader keys the same way). Esc lands here as one of those. 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) { output_pane.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; } } } /// move focus to the nearest pane in `dir` of `from` (overlap-aware /// nearest edge). `from` is the pane the builtin ran on, which is the /// active one for a key but the CLICKED one for a name executed in a tag. pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { const a = p.rects[from]; var best: ?usize = null; var best_d: i32 = 0; for (p.panes, 0..) |slot, i| { if (slot == null or i == from) continue; const r = p.rects[i]; const vov = a.y < r.y + r.h and r.y < a.y + a.h; const hov = a.x < r.x + r.w and r.x < a.x + a.w; const ok = switch (dir) { .left => r.x + r.w <= a.x and vov, .right => r.x >= a.x + a.w and vov, .up => r.y + r.h <= a.y and hov, .down => r.y >= a.y + a.h and hov, }; if (!ok) continue; const d: i32 = switch (dir) { .left => @as(i32, a.x) - @as(i32, r.x + r.w), .right => @as(i32, r.x) - @as(i32, a.x + a.w), .up => @as(i32, a.y) - @as(i32, r.y + r.h), .down => @as(i32, r.y) - @as(i32, a.y + a.h), }; if (best == null or d < best_d) { best = i; best_d = d; } } if (best) |b| { p.active = b; // a count typed before the hop was meant for the pane you left p.panes[b].?.pending = 0; } } // ---- move-drag placement ---- const MovePlacement = struct { preview_col: usize, above_id: usize, row: u16, above_y: u16, above_h: u16, }; fn targetColumn(p: *Pardes, cur_x: u16) usize { var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0; for (0..p.ncol) |c| { if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) { tc = c; break; } } return tc; } fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { if (h < 2) return null; const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; const lo = y +| min_each; const hi = y + h - min_each; if (lo > hi) return y + h / 2; return std.math.clamp(cur_y, lo, hi); } fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { const src = p.layoutFindTerm(id) orelse return null; const tc = p.targetColumn(cur_x); if (tc == src.col and p.col_n[src.col] == 1) return null; if (tc == src.col) { const sr = p.rects[id]; if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; } var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; heights[pid] = p.rects[pid].h; } } if (p.col_n[src.col] > 1) { const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1]; heights[sib] +|= p.rects[id].h; } var y: u16 = TOPBAR_H; var last: ?MovePlacement = null; for (0..p.col_n[tc]) |k| { const pid = p.col_terms[tc][k]; if (pid == id) continue; const h = heights[pid]; const row = splitRowForExtent(y, h, cur_y) orelse { y +|= h; continue; }; const placement: MovePlacement = .{ .preview_col = tc, .above_id = pid, .row = row, .above_y = y, .above_h = h, }; last = placement; if (cur_y < y + h) return placement; y +|= h; } return last; } /// drop pane `id` below the pane under the cursor, converting on-screen /// heights to weights so ONLY the split pane changes size fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { const placement = p.movePlacement(id, cur_x, cur_y) orelse return; const src = p.layoutFindTerm(id) orelse return; const source_multi = p.col_n[src.col] > 1; var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; heights[pid] = p.rects[pid].h; } } p.layoutRemove(id); if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx]; heights[sib] +|= p.rects[id].h; } const af = p.layoutFindTerm(placement.above_id) orelse return; const upper_h = @max(1, placement.row -| placement.above_y); const lower_h = @max(1, placement.above_h -| upper_h); heights[placement.above_id] = upper_h; heights[id] = lower_h; p.layoutInsert(af.col, af.idx + 1, id); p.setColumnWeights(af.col, &heights); if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights); } fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { if (col >= p.ncol) return; for (0..p.col_n[col]) |k| { const pid = p.col_terms[col][k]; if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); } } // ---- helix-modal normal mode ---- const PaneLines = struct { lines: []const []const u8, row0: i32, // absolute row of lines[0] }; /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid with the edit buffer's lines /// standing in for the rows it covers, so motions ride the scrollback and /// the typed text alike. Scratch-arena backed. /// (pub only for test/hxdiff.zig — the helix differential harness dumps /// this surface as a tty case's final text.) pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { const arena = p.scratch.allocator(); if (pane.file) |*f| return .{ .lines = try file_pane.cursorLines(arena, pane, f), .row0 = 0 }; if (hasPdf(pane)) { if (comptime pdf_enabled) return .{ .lines = try pdf_pane.textLines(&pane.pdf.?, p.pdf_gpa, arena), .row0 = 0, }; unreachable; } return .{ .lines = try term_pane.cursorLines(p, pane), .row0 = 0 }; } fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 { if (pane.file != null) return file_pane.byteAtRowDisplay(pane, row, from_raw, display_col); const pl = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col); const local = row - pl.row0; if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len) return @max(0, from_raw + display_col); return @intCast(file_pane.byteAtDisplayFrom( pl.lines[@intCast(local)], @intCast(@max(0, from_raw)), @intCast(@max(0, display_col)), )); } /// Freeze the live terminal cursor into the modal coordinate space. The /// emulator reports screen cells; editing state stores UTF-8 byte offsets. fn pinPaneCursor(p: *Pardes, pane: *Pane) void { if (pane.cur_pinned) return; pane.pinCursor(); if (pane.file == null and !hasPdf(pane)) pane.cur_col = p.paneByteAtDisplay(pane, pane.cur_row, 0, pane.cur_col); } fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor { const r: i32 = pane.cur_row - pl.row0; return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) }; } fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void { pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0; pane.cur_col = @intCast(c.col); pane.cur_pinned = true; } fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { if (lines.len == 0) return c; const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; const target = lines[row]; if (target.len == 0) return .{ .row = row, .col = 0 }; const source = if (c.row < lines.len) lines[c.row] else ""; const goal = file_pane.rawDisplayCol(source, c.col); const mapped = file_pane.rawAtDisplay(target, goal); const last = modal.prevGrapheme(target, target.len); return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) }; } // ---- helix range plumbing (see modal.zig "helix range engine") ---- // The pane's cursor + vsel cells render ONE helix gap range over the flat // motion surface. Every motion builds the current range, transforms it the // way the helix command would, and writes it back: normal mode REPLACES // the selection with the motion's range, select mode (v) extends it via // put_cursor. The differential suite (zig build hxdiff) pins all of this // against a real helix, key for key. /// the flat motion surface: file content as-is; terminals join the /// cursor-lines dump (scratch-arena backed, same lifetime as pl) fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { if (pane.file) |f| return f.content; if (hasPdf(pane)) { if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa); unreachable; } return term_pane.flatSurface(p, pane, pl.lines); } fn paneOff(pane: *Pane, text: []const u8, c: modal.Cursor) usize { if (pane.file != null) return file_pane.textOffset(pane, text, c); return modal.hxOff(text, c); } fn paneLineStart(pane: *Pane, text: []const u8, row: usize) usize { if (pane.file != null) return file_pane.textLineStart(pane, text, row); return modal.lineStartOffset(text, row); } fn paneLineCount(pane: *Pane, text: []const u8) usize { if (pane.file != null) return file_pane.textLineCount(pane, text); return modal.hxLineCount(text); } fn panePos(pane: *Pane, text: []const u8, off: usize) modal.Cursor { if (pane.file != null) return file_pane.textPosition(pane, text, off); return modal.hxPos(text, off); } /// the current selection as a helix gap range over `text`, whose first /// line is absolute row `row0` (0 for the motion surface and for file /// content; a terminal's edit buffer starts wherever it was anchored) fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { const c = paneOff(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); if (pane.msel.active) { // legacy line selection (file-search results highlight): linewise const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); const s = modal.lineStartOffset(text, r0); const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; } if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col); return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; } /// a gap range from its two block-cursor CELLS — the arithmetic paneRange /// does for cur/vsel, shared with the extra selections, which are stored /// in exactly the same shape fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.HxRange { const a = modal.hxOff(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); const c = modal.hxOff(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); return cellOffRange(text, a, c); } /// the same, from the two cells' gap offsets fn cellOffRange(text: []const u8, a: usize, c: usize) modal.HxRange { if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; } /// the inverse: a gap range's cursor and anchor CELLS (equal for a bare /// 1-wide cursor). setPaneRange's own conversion, factored out so the /// extra selections write back through the same three lines. fn rangeCells(text: []const u8, r: modal.HxRange) struct { cur: usize, anc: usize } { if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor }; if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) }; return .{ .cur = r.head, .anc = r.head }; } /// write a helix range back into pane state. `explicit` marks user-intent /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords /// act only on those; motion residue stays implicit. fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void { var r = r0; if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 const off = rangeCells(text, r); const cc = panePos(pane, text, off.cur); // a bare block cursor has both cells on the same offset — the common // case by far — and this conversion is not free even indexed const ac = if (off.anc == off.cur) cc else panePos(pane, text, off.anc); pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; pane.cur_col = @intCast(cc.col); pane.vsel = .{ .active = off.anc != off.cur or pane.select, .row = @as(i32, @intCast(ac.row)) + pl.row0, .col = @intCast(ac.col), .explicit = explicit or pane.select, }; pane.msel.active = false; pane.nsel = 0; // writing ONE range means the selection IS that range pane.cur_pinned = true; pane.sticky_col = -1; pane.pending = 0; pane.ensureCursorVisible(); } // ---- the OTHER selections (helix Selection.ranges / primary_index) ---- // Two functions read and write the whole list; everything else in this // file still speaks the single primary range, and replaySels below is what // makes an ordinary key act at every cursor. /// The whole selection as helix gap ranges over `text`, DOCUMENT ORDER /// (pane.sels is kept that way, so this only has to slot the primary in). /// Returns how many were written and which index is the primary. fn paneRanges(pane: *Pane, text: []const u8, row0: i32, out: *[MAX_SELS]modal.HxRange) struct { n: usize, pri: usize } { const pr = paneRange(pane, text, row0); var n: usize = 0; var pri: usize = 0; var placed = false; for (pane.sels[0..pane.nsel]) |s| { const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col); if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) { pri = n; out[n] = pr; n += 1; placed = true; } out[n] = r; n += 1; } if (!placed) { pri = n; out[n] = pr; n += 1; } return .{ .n = n, .pri = pri }; } /// Write a whole selection back — helix's `Selection::new`: min-width-1, /// sorted by start, overlapping ranges merged (the primary following its /// range through a merge). `ranges[pri]` lands in the primary slot through /// setPaneRange, so nothing downstream can tell it apart from a lone /// cursor; the rest become pane.sels. `sticky` carries each range's own /// j/k goal column, -1 for the ones that have none. fn setPaneRanges(pane: *Pane, pl: PaneLines, text: []const u8, in: []const modal.HxRange, sticky: []const i32, pri0: usize, explicit: bool) void { if (in.len == 0) return; // helix asserts non-empty; here it just means "no change" var r: [MAX_SELS]modal.HxRange = undefined; var st: [MAX_SELS]i32 = undefined; var n: usize = @min(in.len, MAX_SELS); var pri: usize = @min(pri0, n - 1); for (in[0..n], 0..) |x, i| { r[i] = x; if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head); st[i] = if (i < sticky.len) sticky[i] else -1; } // insertion sort by start — n is tiny and usually already ordered var i: usize = 1; while (i < n) : (i += 1) { var j = i; while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) { std.mem.swap(modal.HxRange, &r[j], &r[j - 1]); std.mem.swap(i32, &st[j], &st[j - 1]); if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j; } } // merge overlaps (helix Range::overlaps + Range::merge, kept forward: // a merged range takes the union and loses its direction only when the // two disagree, which is what helix's else-branch does) var k: usize = 0; i = 1; while (i < n) : (i += 1) { const a = r[k]; const b = r[i]; const af = @min(a.anchor, a.head); const at = @max(a.anchor, a.head); const bf = @min(b.anchor, b.head); const bt = @max(b.anchor, b.head); if (af == bf or (at > bf and bt > af)) { r[k] = if (a.anchor > a.head and b.anchor > b.head) .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) } else .{ .anchor = @min(af, bf), .head = @max(at, bt) }; if (pri == i) pri = k; if (st[k] < 0) st[k] = st[i]; continue; } k += 1; r[k] = b; st[k] = st[i]; if (pri == i) pri = k; } n = k + 1; setPaneRange(pane, pl, text, r[pri], explicit); pane.sticky_col = st[pri]; var w: usize = 0; for (r[0..n], 0..) |x, idx| { if (idx == pri) continue; const c = rangeCells(text, x); const cc = modal.hxPos(text, c.cur); const ac = modal.hxPos(text, c.anc); pane.sels[w] = .{ .row = @as(i32, @intCast(cc.row)) + pl.row0, .col = @intCast(cc.col), .arow = @as(i32, @intCast(ac.row)) + pl.row0, .acol = @intCast(ac.col), .sticky = st[idx], }; w += 1; } pane.nsel = @intCast(w); } /// The helix keys that act on the selection LIST rather than on the text. /// Each reads the whole list and writes a whole list back; none is a /// motion, which is why Action.scope marks them exempt from per-range /// replay. The goal columns are dropped on the way through — every /// one of these is a fresh intent about WHERE the cursors are, the same /// reason setPaneRange resets sticky_col. fn multiSelAction(pane: *Pane, pl: PaneLines, text: []const u8, kind: normal_input.Multi, cnt: usize) void { var rs: [MAX_SELS]modal.HxRange = undefined; const got = paneRanges(pane, text, pl.row0, &rs); const n = got.n; const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (kind == .remove_primary) { if (n < 2) return; // helix: "no selections remaining" var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; for (rs[0..n], 0..) |r, i| { if (i == got.pri) continue; out[m] = r; m += 1; } // helix Selection::remove: the NEXT range takes over, or the // previous one when the primary was last return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); } if (kind == .rotate_forward or kind == .rotate_backward) { const step = cnt % n; const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n; return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl); } if (kind == .merge) { // helix merge_selections: first.merge(last) — the ranges are // sorted, so that is simply the whole span const lo = @min(rs[0].anchor, rs[0].head); const hi = @max(rs[n - 1].anchor, rs[n - 1].head); const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head; const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl); } if (kind == .merge_consecutive) { // ranges that TOUCH become one; setPaneRanges already merges the // ones that overlap, so widening each by a grapheme says exactly // "consecutive counts as overlapping" and nothing else var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; var pri: usize = 0; for (rs[0..n], 0..) |r, i| { if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) { const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head)); const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head)); out[m - 1] = .{ .anchor = lo, .head = hi }; if (i == got.pri) pri = m - 1; continue; } if (i == got.pri) pri = m; out[m] = r; m += 1; } return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); } if (kind == .split_newline) { // helix selection::split_on_newline — one range per line the // selection covers, the newlines themselves left out var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; for (rs[0..n]) |r| { const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); if (from == to) { if (m < MAX_SELS) { out[m] = r; m += 1; } continue; } var start = from; while (start < to and m < MAX_SELS) { const eol = modal.hxLineEndIdx(text, modal.hxLineOf(text, start)); if (eol >= to) { out[m] = .{ .anchor = start, .head = to }; m += 1; break; } out[m] = .{ .anchor = start, .head = eol }; m += 1; start = eol + 1; } } if (m == 0) return; return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 } if (kind == .trim) { // helix trim_selections: whitespace off both ends; ranges that are // empty or all whitespace are dropped entirely var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; for (rs[0..n]) |r| { var from = @min(r.anchor, r.head); var to = @max(r.anchor, r.head); while (from < to and std.ascii.isWhitespace(text[from])) from += 1; while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1; if (from >= to) continue; out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }; m += 1; } if (m == 0) { // helix: collapse_selection + keep_primary_selection const c = modal.hxCursor(text, rs[got.pri]); return setPaneRange(pane, pl, text, .{ .anchor = c, .head = c }, false); } // helix: the first survivor that OVERLAPS the old primary, else the last const pf = @min(rs[got.pri].anchor, rs[got.pri].head); const pt = @max(rs[got.pri].anchor, rs[got.pri].head); var pri = m - 1; for (out[0..m], 0..) |r, i| { const f = @min(r.anchor, r.head); const t = @max(r.anchor, r.head); if (f == pf or (t > pf and pt > f)) { pri = i; break; } } return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); } // C / Alt-C — helix copy_selection_on_line, a copy of each range on the // next/previous line that is long enough to hold its columns. // ponytail: BYTE columns, not helix's visual ones, so a TAB counts as // one column here. Everything else in this file measures the same way // (hscroll, the mouse, the renderer's gutter), and fixing it means // teaching all of them tab stops at once. const below = kind == .copy_below; var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; var pri: usize = 0; const nlines = modal.hxLineCount(text); for (rs[0..n], 0..) |r, ri| { const is_pri = ri == got.pri; // head-exclusive: back the leading end off onto its own cell const hp = modal.hxPos(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); const ap = modal.hxPos(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor)); const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1; if (m >= MAX_SELS) break; if (is_pri) pri = m; out[m] = r; m += 1; var made: usize = 0; var k: usize = 0; while (made < cnt and m < MAX_SELS) : (k += 1) { const d = (k + 1) * height; const arow = if (below) ap.row + d else ap.row -| d; const hrow = if (below) hp.row + d else hp.row -| d; if (arow >= nlines or hrow >= nlines) break; const a2 = modal.hxOff(text, .{ .row = arow, .col = ap.col }); const h2 = modal.hxOff(text, .{ .row = hrow, .col = hp.col }); // a line too short to reach the column is skipped, not clamped if (modal.hxPos(text, a2).col == ap.col and modal.hxPos(text, h2).col == hp.col) { if (is_pri) pri = m; out[m] = modal.hxPutCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); m += 1; made += 1; } if (arow == 0 and hrow == 0) break; } } setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); } // ---- `s` / `S`: the selection LIST from a regex ---- // The other two list-making keys, and the only ones that need a pattern // typed first. They reuse the `/` input wholesale (startSearch — the tag // tail IS the prompt) and differ from it in one thing: the pattern is // re-applied on every keystroke, so the selection is the preview. /// helix `s` / `S`: arm the tag input for a regex, remembering the /// selection it is about to rewrite. Nothing moves yet — every keystroke /// below re-derives the preview from this snapshot, and Enter simply stops /// while Esc puts the snapshot back. fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const got = paneRanges(pane, text, pl.row0, &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.startSearch(pane, if (split) config.split_marker else config.select_marker); } /// The pattern an armed `s`/`S` input holds right now, and which of the two /// it is — read back off the MARKER, exactly the way submitSearch decides /// which search is running. Null for `/`, Find, Grep and Rename. 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 }; } /// Put the selection back the way `s`/`S` found it and then, if `pat` /// compiles and hits, rewrite it: helix's select_on_matches (every match /// INSIDE each range becomes a range) and split_on_matches (each range /// becomes the pieces BETWEEN its matches). /// /// Anything that yields nothing — an empty pattern, one that will not /// compile, one that does not match — leaves the snapshot standing, which /// is helix's "nothing selected" and also what makes typing a pattern one /// character at a time bearable: every prefix of it is one of those. /// /// ponytail: MAX_SELS ranges, and matches past that are dropped rather /// than growing the list — the ceiling the whole selection model has. /// ponytail: mvzr searches from each match's end, so `^` and `$` assert /// against THAT position rather than against a line the way helix's /// multi_line regex does, and `.` matches a newline like any other byte. /// Both are waived. `[^\n]` LOOKS like the workaround for the second and /// must not be suggested as one: the live preview compiles every prefix, /// and the prefix `[^\` panics mvzr (index out of bounds in parseCharSet, /// mvzr.zig valueFor) before the pattern can ever be finished. Guarding /// the compile is what would make the advice sayable. fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; if (pat.len > 0) if (mvzr.compile(pat)) |re| { // helix smart-case: a pattern with no uppercase in it matches // case-blind. mvzr has no such flag — "lowercase your string" is // its own advice — and ASCII folding is byte for byte, so a // lowercased copy of the surface has exactly the same offsets. 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 < MAX_SELS) { 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) { // a match sitting right off the END of the range is // dropped (helix: what `\b` and empty matches produce // there), everything else becomes a range 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 < MAX_SELS) { 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 setPaneRanges(pane, pl, text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } /// Pane keeps compact codepoints for dump/layout stability; this pair is /// the only bridge to the parser's typed state. Recognition never reads /// these representation fields directly. fn paneNormalState(pane: *const Pane) normal_input.State { const prefix: normal_input.Prefix = if (pane.pending == config.goto_prefix) .goto else if (pane.pending == config.view_prefix) .view else if (pane.pending == config.match_prefix) .match else if (pane.pending == config.find_char_fwd) .find_fwd else if (pane.pending == config.find_char_back) .find_back else if (pane.pending == config.till_char_fwd) .till_fwd else if (pane.pending == config.till_char_back) .till_back else if (pane.pending == config.replace_prefix) .replace else if (pane.pending == config.next_prefix) .next else if (pane.pending == config.prev_prefix) .prev else .none; const match_sub: normal_input.MatchSub = if (pane.pending2 == config.match_inside) .inside else if (pane.pending2 == config.match_around) .around else if (pane.pending2 == config.surround_add) .surround_add else if (pane.pending2 == config.surround_replace) .surround_replace else if (pane.pending2 == config.surround_delete) .surround_delete else .none; return .{ .count = pane.count, .prefix = prefix, .match_sub = match_sub, .held_char = pane.pending_ch, }; } fn putPaneNormalState(pane: *Pane, state: normal_input.State) void { pane.count = state.count; pane.pending = switch (state.prefix) { .none => 0, .goto => config.goto_prefix, .view => config.view_prefix, .match => config.match_prefix, .find_fwd => config.find_char_fwd, .find_back => config.find_char_back, .till_fwd => config.till_char_fwd, .till_back => config.till_char_back, .replace => config.replace_prefix, .next => config.next_prefix, .prev => config.prev_prefix, }; pane.pending2 = switch (state.match_sub) { .none => 0, .inside => config.match_inside, .around => config.match_around, .surround_add => config.surround_add, .surround_replace => config.surround_replace, .surround_delete => config.surround_delete, }; pane.pending_ch = state.held_char; } /// Everything one keystroke may CONSUME on the way through the modal /// handler. A key means the same thing at every cursor, so the replay puts /// all of it back before each pass and keeps whatever the PRIMARY's pass /// left. (sticky_col is deliberately absent: it is per-range, and rides /// along in SelRange.sticky instead.) const KeyState = struct { mode: Mode, select: bool, count: u32, pending: u21, pending2: u21, pending_ch: u21, find_op: u8, find_ch: u21, append_at: @FieldType(Pane, "append_at"), fn of(pane: *Pane) KeyState { return .{ .mode = pane.mode, .select = pane.select, .count = pane.count, .pending = pane.pending, .pending2 = pane.pending2, .pending_ch = pane.pending_ch, .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.count = s.count; pane.pending = s.pending; pane.pending2 = s.pending2; pane.pending_ch = s.pending_ch; 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: normal_input.Action, insert: Key, }; /// Run one keystroke at EVERY cursor, by replaying the single-selection /// handler once per range. This IS the multiple-cursor mechanism, and it /// is why one cursor costs nothing: with `nsel == 0` nobody calls it, and /// the handler underneath is the same code the 800 differential cases pin. /// /// Two rules make the replay legal without helix's change-mapping: /// * ranges are visited LAST FIRST, so an edit never disturbs the /// row/col of a range still waiting its turn — everything it touches /// is below. /// * a finished pass's result is recorded as a distance from the END of /// the surface, which an edit strictly before it cannot change (the /// text and the position shift by exactly the same amount). 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: [MAX_SELS]SelRange = undefined; var n: usize = 0; var pri: usize = 0; const prim: 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 = selStart(prim); var placed = false; for (pane.sels[0..pane.nsel]) |s| { const sr = 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: [MAX_SELS]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| p.insertKey(pane, insert_key), } // the stop check comes FIRST: the pass that set it may have freed // this very pane (a builtin closing it), so nothing below may read // through the pointer if (p.multi_stop) break; if (i == pri) { after = KeyState.of(pane); after_expl = pane.vsel.explicit; } const pl = p.paneCursorLines(pane) catch { p.multi_stop = true; // out of memory mid-replay: collapse, don't guess break; }; const text = p.flatSurface(pane, pl) catch { p.multi_stop = true; break; }; const co = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - pl.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const ao = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - pl.row0)), .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) { // the pass reached outside the buffer (a builtin, a language // query) and may have closed or reused the pane it ran on: drop // back to one cursor rather than replaying it n more times p.multi_stop = false; const pn = p.panes[id] orelse return; if (pn.serial == serial) pn.nsel = 0; return; } const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; var rs: [MAX_SELS]modal.HxRange = undefined; var st: [MAX_SELS]i32 = undefined; for (res[0..n], 0..) |r, k| { rs[k] = cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); st[k] = r.sticky; } setPaneRanges(pane, pl, text, rs[0..n], st[0..n], pri, after_expl); after.into(pane); pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass } /// a range's start CELL (document order key) — the smaller of its two ends fn selStart(s: SelRange) struct { row: i32, col: i32 } { if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol }; return .{ .row = s.row, .col = s.col }; } /// The last line a goto may land on: helix skips the empty trailing line. /// Called from the three `g`/`G` Action branches that need it and nowhere /// else — it used to be eager at the top of body-normal execution, so every /// keystroke of every kind paid a full count of the buffer's newlines. /// "Does the buffer end in a newline" is the same question as the walk to /// the last line start that stood here, and it is one byte instead of a /// second pass. fn maxLine(text: []const u8) usize { const nl = modal.hxLineCount(text); return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; } /// point-target motion: collapse there (extend in select mode) fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); } /// word motions select their traversed span (extend mode: head only) fn wordMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize, target: modal.WordTarget) void { const wr = modal.hxWordMove(text, range, cnt, target); const res = if (pane.select) modal.hxPutCursor(text, range, modal.hxCursor(text, wr), true) else wr; setPaneRange(pane, pl, text, res, false); } /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void { const cur = modal.hxCursor(text, range); const t = modal.hxFindTarget(text, cur, ch, fwd, till, cnt) orelse return; const res = if (pane.select) modal.hxPutCursor(text, range, t, true) else modal.hxPutCursor(text, .{ .anchor = cur, .head = cur }, t, true); setPaneRange(pane, pl, text, res, false); } /// j/k and friends: sticky goal column, clamped onto short lines' newline fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void { const cur = modal.hxCursor(text, range); const pos = panePos(pane, text, cur); const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else file_pane.rawDisplayCol(modal.lineSlice(text, pos.row), pos.col); // modal.hxVertTarget with the row we already have and the indexed // offset conversion — it would otherwise recount the buffer's newlines // and walk to the target line, two more full passes per j/k const last_row = paneLineCount(pane, text) - 1; const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt); const target_col = file_pane.rawAtDisplay(modal.lineSlice(text, nline), goal); const t = paneOff(pane, text, .{ .row = nline, .col = target_col }); // extend mode never walks onto the empty trailing line (helix) if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); pane.sticky_col = @intCast(goal); } /// `gj`/`gk`: one VISUAL line, following the automatic breaks a wrapped /// body draws rather than the newlines in the file. The goal column is the /// one INSIDE the visual row, so a run of them walks straight down a /// paragraph; on the last visual row of a line the step crosses into the /// next line's first row, exactly as the eye does. /// /// With wrapping off — Wrap unset, a terminal pane, an output pane too /// narrow to record its map — a line is one visual row and this IS /// verticalMove, which is why nothing upstream branches on the setting. fn visualMove( pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize, width: usize, ) void { const cur = modal.hxCursor(text, range); const pos = panePos(pane, text, cur); const last_row = paneLineCount(pane, text) - 1; var row = pos.row; var line = modal.lineSlice(text, row); var vrow = file_pane.visualRow(line, pos.col, width); const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else file_pane.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start); var steps = @max(1, cnt); while (steps > 0) : (steps -= 1) { if (down) { if (vrow.end < line.len) { vrow = file_pane.visualRow(line, vrow.end, width); continue; } if (row == last_row) break; row += 1; line = modal.lineSlice(text, row); vrow = file_pane.visualRow(line, 0, width); } else { if (vrow.start > 0) { vrow = file_pane.visualRow(line, vrow.start - 1, width); continue; } if (row == 0) break; row -= 1; line = modal.lineSlice(text, row); vrow = file_pane.visualRow(line, line.len, width); } } // The newline slot is a real cursor position, but the first byte of the // NEXT visual row is not: landing there would read as two rows moved. var target_col = vrow.start + file_pane.rawAtDisplay(line[vrow.start..vrow.end], goal); if (vrow.end < line.len and target_col >= vrow.end) target_col = modal.graphemeStart(line, vrow.end - 1); const t = paneOff(pane, text, .{ .row = row, .col = target_col }); // extend mode never walks onto the empty trailing line (helix) if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); pane.sticky_col = @intCast(goal); } /// How wide a wrapped row of this pane is, or 0 when it does not wrap. /// Only a file-backed body wraps: a terminal's rows are the emulator's /// own, already broken where it decided to break them. fn paneWrapWidth(p: *const Pardes, pane: *const Pane) usize { if (pane.file == null) return 0; return file_pane.wrapWidth(pane, p.settings.wrap); } /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void { const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); pane.scrollBy(if (down) half else -half); verticalMove(pane, pl, text, range, down, @intCast(half)); } /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void { const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); pane.scrollBy(delta); const top = pane.scroll(); const last_row: i32 = @intCast(paneLineCount(pane, text) - 1); const cur = modal.hxCursor(text, range); if (delta > 0) { const snap: i32 = @max(0, @min(top + margin, last_row)); const head = paneLineStart(pane, text, @intCast(snap)); if (head <= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); } else { const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); const head = paneLineStart(pane, text, @intCast(snap)); if (head >= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); } } /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void { const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const top = pane.scroll(); const last_row: i32 = @intCast(paneLineCount(pane, text) - 1); const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); const n: i32 = @intCast(cnt - 1); var vline: i32 = switch (which) { .top => top + margin + n, .center => top + @divTrunc(last_vis, 2), .bottom => top + last_vis - (margin + n), }; vline = @max(vline, top + margin); vline = @min(vline, top + last_vis - margin); const row: i32 = std.math.clamp(vline, 0, last_row); pointMove(pane, pl, text, range, paneLineStart(pane, text, @intCast(row))); } /// helix Range::line_range — the inclusive line span a range covers fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } { const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) }; } fn lineStartOrEof(text: []const u8, line: usize) usize { if (line >= modal.hxLineCount(text)) return text.len; return modal.lineStartOffset(text, line); } /// helix `x` extend_line_below: full lines incl. the newline, cursor ON /// the last one's '\n'; an already-line-bounded selection grows downward fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void { const span = rangeLineSpan(text, range); const start = modal.lineStartOffset(text, span.start); const end = lineStartOrEof(text, span.end + 1); const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true); } /// helix `X` extend_to_line_bounds (direction kept) fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { const span = rangeLineSpan(text, range); const start = modal.lineStartOffset(text, span.start); const end = lineStartOrEof(text, span.end + 1); const r: modal.HxRange = if (range.head < range.anchor) .{ .anchor = end, .head = start } else .{ .anchor = start, .head = end }; setPaneRange(pane, pl, text, r, true); } /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { const span = rangeLineSpan(text, range); if (span.start == span.end) return; const from = @min(range.anchor, range.head); const to = @max(range.anchor, range.head); var start = modal.lineStartOffset(text, span.start); var end = lineStartOrEof(text, span.end + 1); if (start != from) start = lineStartOrEof(text, span.start + 1); if (end != to) end = modal.lineStartOffset(text, span.end); const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; const r: modal.HxRange = if (range.head < range.anchor) .{ .anchor = end, .head = start } else .{ .anchor = start, .head = end }; setPaneRange(pane, pl, text, r, expl); } /// Keys that are about the selection LIST, or about the session rather /// than the text: they act ONCE however many cursors there are. The /// multi-cursor family rewrites the list wholesale, and the rest would /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC). /// fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { var state = paneNormalState(pane); const parsed = normal_input.parse(&state, normalInput(key)); putPaneNormalState(pane, state); 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 }); } /// Text-pane adapter for the semantic BODY-NORMAL vocabulary. Parsing is /// complete before this function runs; this switch reads document state /// only to execute the already-recognized action. fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: normal_input.Action) void { p.pinPaneCursor(pane); const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const lines = pl.lines; const range = paneRange(pane, text, pl.row0); const cur = modal.hxCursor(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, maxLine(text)) else 0; return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); }, .last_line => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text))), .line_start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), .line_end => { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); }, .first_nonws => { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.hxLineEndIdx(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pointMove(pane, pl, text, range, ls + nw); }, .line_down => return visualMove(pane, pl, text, range, true, go.count, p.paneWrapWidth(pane)), .line_up => return visualMove(pane, pl, text, range, false, go.count, p.paneWrapWidth(pane)), .column => { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.hxLineEndIdx(text, line)]; return pointMove(pane, pl, text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); }, .view_top => return gotoWindow(pane, pl, text, range, .top, go.count), .view_center => return gotoWindow(pane, pl, text, range, .center, go.count), .view_bottom => return gotoWindow(pane, pl, 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 scrollViewMove(pane, pl, text, range, 1), .scroll_up => return scrollViewMove(pane, pl, 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 findMove( pane, pl, 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 p.normalReplaceChar(pane, char), .match_bracket => { const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); }, .textobject => |object| return p.textobjectSelect(pane, pl, object.char, object.around), .surround_add => |char| return p.surroundAdd(pane, char), .surround_delete => |char| return p.surroundDelete(pane, pl, char), .surround_replace => |replace| return p.surroundReplace(pane, pl, replace.from, replace.to), .paragraph => |paragraph| { const r2 = modal.hxParaMove(text, range, paragraph.count, paragraph.direction == .forward, pane.select); return setPaneRange(pane, pl, text, r2, false); }, .add_newline => |newline| return p.addNewline(pane, newline.direction == .forward, newline.count), .diagnostic => |diagnostic| { const fwd = diagnostic.direction == .forward; if (!diagnostic.endpoint) { if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } if (!output_pane.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 pointMove(pane, pl, text, range, target); }, .right => { var target = cur; for (0..move.count) |_| target = modal.nextGrapheme(text, target); return pointMove(pane, pl, text, range, target); }, .down => return verticalMove(pane, pl, text, range, true, move.count), .up => return verticalMove(pane, pl, text, range, false, move.count), .next_word_start => return wordMove(pane, pl, text, range, move.count, .next_word_start), .prev_word_start => return wordMove(pane, pl, text, range, move.count, .prev_word_start), .next_word_end => return wordMove(pane, pl, text, range, move.count, .next_word_end), .next_long_word_start => return wordMove(pane, pl, text, range, move.count, .next_long_word_start), .prev_long_word_start => return wordMove(pane, pl, text, range, move.count, .prev_long_word_start), .next_long_word_end => return wordMove(pane, pl, 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.hxCursor(text, repeated); const target = modal.hxFindTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; repeated = if (pane.select) modal.hxPutCursor(text, repeated, target, true) else modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, target, true); moved = true; } if (!moved) return; return setPaneRange(pane, pl, text, repeated, false); }, .line => |line_kind| switch (line_kind) { .start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), .end => { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); }, .first_nonws => { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.hxLineEndIdx(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pointMove(pane, pl, text, range, ls + nw); }, }, .goto_line => |go| { if (!go.explicit) return; const line = @min(@as(usize, go.count) - 1, maxLine(text)); return pointMove(pane, pl, 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 halfPageMove(pane, pl, text, range, true), .half_up => return halfPageMove(pane, pl, text, range, false), .down => return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)), .up => return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)), }, .insert => |insert| { const where: 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 p.enterInsert(pane, where, insert.count); }, .select => |select| switch (select.kind) { .mode => { if (pane.select) { pane.select = false; } else { pane.select = true; if (pane.msel.active) { setPaneRange(pane, pl, text, 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 lineSelect(pane, pl, text, range, select.count), .line_bounds => return lineBoundsSelect(pane, pl, text, range), .shrink_to_line_bounds => return shrinkSelToLineBounds(pane, pl, text, range), .collapse => return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false), .flip => { const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, explicit); }, .all => return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { if (multi.kind == .keep_primary) { pane.nsel = 0; return; } return multiSelAction(pane, pl, text, multi.kind, multi.count); }, .select_regex => |split| return p.startSelRegex(pane, split), .edit => |edit| switch (edit.kind) { .delete => return p.normalDelete(pane, true), .delete_noyank => return p.normalDelete(pane, false), .change => return p.normalChange(pane), .yank => return p.normalYank(pane), .replace_with_yank => return p.normalReplaceYank(pane), .paste_after => { pane.count = edit.count; return p.normalPaste(pane, false); }, .paste_before => { pane.count = edit.count; return p.normalPaste(pane, true); }, .switch_case => return p.normalCase(pane, .toggle), .lowercase => return p.normalCase(pane, .lower), .uppercase => return p.normalCase(pane, .upper), .join_lines => return p.normalJoin(pane), .indent => return p.normalIndent(pane, edit.count, true), .unindent => return p.normalIndent(pane, edit.count, false), .comment_toggle => return p.normalToggleComment(pane), .undo => return p.doUndo(pane), .redo => return p.doRedo(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 p.normalAdjustNumber(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 => return p.startPipe(pane), .search => return p.startSearch(pane, 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 ---- fn startPipe(p: *Pardes, pane: *Pane) void { // A buffer that IS a file, or the scratch that becomes one: a filter // rewrites bytes the pane owns. Never a terminal (shell output cannot // be rewritten), never a rendering that its next refill would discard. const f = pane.file orelse return; if (!output_pane.fileTraits(f.output).saves) return; p.seedTail(pane); if (!pane.tag_init) return; const prompt_at: u16 = @intCast(pane.tag_tail_len); if (!pane.appendTag(config.pipe_marker)) return; pane.prompt = .{ .pipe = prompt_at }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.pending = 0; pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); } /// Snapshot command/cwd/ranges/selection bytes before emitting the id-only /// effect. Every allocation is owned by pipe_wait, so the frontend can /// copy it synchronously and the core can keep editing immediately after. fn submitPipe(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const f = pane.file orelse return; if (!output_pane.fileTraits(f.output).saves) return; const tail = pane.tagSlice(); const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; if (!std.mem.startsWith(u8, armed, config.pipe_marker)) return; const command = armed[config.pipe_marker.len..]; if (command.len == 0) return; var ranges: [MAX_SELS]modal.HxRange = undefined; const got = paneRanges(pane, f.content, 0, &ranges); const inputs = p.gpa.alloc(selection_pipe.Input, got.n) catch return; var made: usize = 0; for (ranges[0..got.n], 0..) |range, i| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > f.content.len) break; const copy = p.gpa.dupe(u8, f.content[lo..hi]) catch break; inputs[i] = .{ .bytes = copy }; made += 1; } if (made != got.n) { for (inputs[0..made]) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; } const command_copy = p.gpa.dupe(u8, command) catch { for (inputs) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; }; const cwd_copy = p.gpa.dupe(u8, paneDir(pane)) catch { p.gpa.free(command_copy); for (inputs) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; }; p.pipe_seq +%= 1; if (p.pipe_wait) |*old| old.deinit(p.gpa); p.pipe_wait = .{ .id = p.pipe_seq, .pane = id, .serial = pane.serial, .revision = f.revision, .command = command_copy, .cwd = cwd_copy, .inputs = inputs, .ranges = ranges, .primary = @intCast(got.pri), .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, }; p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); } /// Frontends call this while draining a .pipe effect, then immediately /// copy the borrowed view into a worker-owned selection_pipe.Job. pub fn pipeRequest(p: *const Pardes, id: u32) ?selection_pipe.Request { if (p.pipe_wait) |*wait| { if (wait.id == id) return wait.request(); } return null; } fn pipeResponse(p: *Pardes, id: u32, success: bool, outputs: []const []const u8) void { if (p.pipe_wait == null or p.pipe_wait.?.id != id) return; var wait = p.pipe_wait.?; p.pipe_wait = null; defer wait.deinit(p.gpa); if (!success or outputs.len != wait.inputs.len) return; const pane = p.panes[wait.pane] orelse return; if (pane.serial != wait.serial) return; const f = if (pane.file) |*file| file else return; if (!output_pane.fileTraits(f.output).saves or f.revision != wait.revision) return; var total_output: usize = 0; var removed: usize = 0; var previous_end: usize = 0; for (outputs, wait.ranges[0..outputs.len]) |output, range| { if (output.len > selection_pipe.max_stdout_bytes) return; total_output = std.math.add(usize, total_output, output.len) catch return; if (total_output > selection_pipe.max_total_stdout_bytes) return; const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (lo < previous_end or hi > f.content.len) return; removed = std.math.add(usize, removed, hi - lo) catch return; previous_end = hi; } const kept = f.content.len - removed; const final_len = std.math.add(usize, kept, total_output) catch return; const replacement = p.gpa.alloc(u8, final_len) catch return; var new_ranges: [MAX_SELS]modal.HxRange = undefined; var read_at: usize = 0; var write_at: usize = 0; for (outputs, wait.ranges[0..outputs.len], 0..) |output, range, i| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); @memcpy(replacement[write_at .. write_at + (lo - read_at)], f.content[read_at..lo]); write_at += lo - read_at; const out_start = write_at; @memcpy(replacement[write_at .. write_at + output.len], output); write_at += output.len; const out_end = write_at; new_ranges[i] = if (range.anchor > range.head) .{ .anchor = out_end, .head = out_start } else .{ .anchor = out_start, .head = out_end }; read_at = hi; } @memcpy(replacement[write_at..], f.content[read_at..]); // One async request is one history transaction, even at 64 cursors. p.pushUndo(pane); file_pane.setContent(p, f, replacement); const pl = p.paneCursorLines(pane) catch return; setPaneRanges(pane, pl, f.content, new_ranges[0..outputs.len], &.{}, wait.primary, wait.explicit); pane.select = false; pane.ensureCursorVisible(); } // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- const Search = enum { text, find, grep }; const SearchStart = enum { top, cursor }; fn paneDir(pane: *const Pane) []const u8 { // an inherited cwd is a live link that outranks a scratch buffer's own // synthetic path: follow it to the pane it was opened from. switch (pane.cwd) { .inherited => |src| return paneDir(src), else => {}, } 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(); } /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `marker` — the existing modal one-line editor, visible while /// typing, nothing that disappears. Enter/Esc are intercepted in handleKey. pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { p.seedTail(pane); if (!pane.tag_init) return; const prompt_at: u16 = @intCast(pane.tag_tail_len); if (!pane.appendTag(marker)) return; pane.prompt = .{ .search = prompt_at }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.pending = 0; // tag_col and the prompt offset are both UTF-8 byte offsets. pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); } /// Save on a pane with no file of its own — an output buffer or a terminal /// — arms a PATH input in the tag, prefilled with the pane's directory (an /// inherited scratch follows the pane it was opened from). submitSave /// hands what you type to saveTo. pub fn startSavePrompt(p: *Pardes, pane: *Pane) void { p.seedTail(pane); if (!pane.tag_init) return; const prompt_at: u16 = @intCast(pane.tag_tail_len); if (!pane.appendTag(config.save_marker)) return; const dir = paneDir(pane); _ = pane.appendTag(dir); if (dir.len == 0 or dir[dir.len - 1] != '/') _ = pane.appendTag("/"); pane.prompt = .{ .save = prompt_at }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.pending = 0; pane.tag_col = @intCast((p.tagPrefix(pane) catch return).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) } }); f.saved_revision = f.revision; } /// Commit a path — prompted, typed after the word, or chorded onto it. /// /// The pane is left ALONE: a terminal stays a terminal, a results buffer /// keeps its rows and its place in the n/N ring, and an open file keeps the /// file it has, so `Save ` is a copy and never a rename. The one /// pane that changes is the scratch New opened, which exists to become the /// file you name and does (output traits: `saves`). /// /// A relative path resolves against the PANE's directory — the way a look /// resolves a relative word — and never against whatever directory the /// process happened to start in. `.`, `..` and doubled slashes normalize /// with it, so `Save ./notes` and `Save notes` are one path and one answer /// to "is this the file I already have open". /// /// What it will not do is guess. A path that names no FILE (empty, or /// ending in `/` — the bare prompt prefill accepted with Enter), one that /// carries a newline (a multi-line selection chorded onto the word), and /// one that does not resolve ABSOLUTE (a terminal whose shell has not /// reported a directory yet, where the alternative is writing into /// whatever directory pardes was started in) are all refused, and say so /// on the message row. That check comes FIRST because the scratch's branch /// below rewrites the pane's identity: a host write can only fail silently /// afterwards, so a buffer must never become a "file" that never existed. 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 output_pane.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 pane.cwd = .none; // its directory is now its own path's dirname f.saved_revision = f.revision; pane.tag_init = false; // re-derive the tag as a plain file pane.tag_tail_len = 0; p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); 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); } /// The bound on a save path, which travels inside its effect: nothing is /// stashed, so two saves armed in one batch cannot be confused for each /// other and a whole buffer is never copied to write it. const SavePath = Effect.Buf(256); /// Ask the host to write this pane's text at `path` without touching the /// pane. The bytes are the pane's own, so the drain reads them back off it /// (perform, .save_text) the way save_file does — with `serial` saying /// which pane asked, since the slot may be freed before the drain. 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), } }); } /// Enter on an armed input: the pattern is everything past the marker's /// `/` (so a pattern may itself contain slashes), and the marker names the /// search — " Find /" walks the filesystem for NAMES, " Grep /" for /// CONTENTS, " /" reads the pane's own text. No `/` left means the editor /// ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; // `s`/`S` have already applied themselves keystroke by keystroke; // Enter re-runs the final pattern so a submit is one code path with // the preview and cannot disagree with what is on screen. if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); const tail = pane.tagSlice(); const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; 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); // ...and the bare `/` GOES there. Find and Grep answer with OTHER // files, and opening the first of them on submit would rearrange the // screen before you have read what was found; `/` searched the text // already in front of you, so its first hit is a scroll, not a jump. if (kind == .text) p.lookFirstHit(id); } /// Land ON the first hit of a `/`, instead of beside a list of them. /// /// No new motion: the results buffer is FOCUSED and then the two ordinary /// verbs run in the order a hand would run them — the step `n` is, and the /// look Enter is. Focusing is the part that cannot be skipped and the /// reason this is not just a call to the walk: lookWalkPanes deliberately /// leaves the ACTIVE pane out of the ring, so `n` pressed straight after a /// search steps whichever list was looked most recently, which in a /// session with any history at all is not the one that just answered. /// /// Everything the walk needs to be reversible from here is left by the /// step itself (landLookSpot's `look_at`), so `N` afterwards goes back to /// the row above exactly as it would have if you had pressed `n` yourself. 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; // Nothing matched: the empty buffer is the answer, and aiming the walk // at a list with no rows would send it round the ring into a NEIGHBOUR // and open a row from some other search. 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 = p.currentSelText(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); } /// Fill this pane's results buffer with everything matching `pat_raw` /// (plain substring, case-insensitive) — ONE function for both searches, /// because they differ only in where the rows come from and agree on every /// row being a LOOK TARGET, which is what makes n/N work: /// text (`/`) — the pane's own flat text, the same view Look and Execute /// read, so a terminal searches its scrollback exactly as a file /// searches its content. Rows are `location text`, the location ended /// by a SPACE (a trailing `:` would read as part of it): the pane's /// path when it has a real file, else `@pN`. /// find — the pane's DIRECTORY, walked like fd. Rows are bare paths. /// grep — the CONTENTS of every file under every pane's directory, /// walked like `grep -R`. Rows are `path:LINE:COL-ENDCOL text`, the /// path relative to THIS pane's directory (absolute for a hit outside /// it). /// Both searches that match TEXT name the match's whole span, so looking a /// row — which is all n/N do — selects what matched rather than parking on /// its first cell. Find's rows are bare paths and have nothing to span. /// No matches = an empty buffer. /// /// `start` is where the WALK begins, which belongs to the gesture and not /// to the search: a click POINTS at one of the hits, so its list is armed /// there and the first step goes to the next one (acme's button-3 walking a /// word). `/`, Find and Grep point at nothing, so their list is walked from /// the top, which is also the only place a list of OTHER files could start. 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, look.search_max_output_bytes); var out_len: usize = 0; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { // One walk per PLACE the session is open on: every pane's // directory, minus the ones another pane's already contains, so a // tree two panes sit in is greped once and a pane deep inside // another's tree adds nothing. Slot order, so the same session // gives the same buffer twice running. 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 look.grep(arena, p.gpa, r, dir, pat, out[out_len..]); } else if (kind == .find) { out_len = try look.find(arena, dir, pat, out); } else if (hasPdf(pane)) { if (comptime pdf_enabled) { const found = try pdf_pane.searchRows( &pane.pdf.?, p.pdf_gpa, arena, pat, start == .cursor, out, ); out_len = found.bytes; nrows = found.rows; anchor = found.anchor; } } else { const pl = try p.paneCursorLines(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; // the hit at or before the cursor is the one you are ON, so arming // there makes the first step land on the NEXT one: a click on the // second `foo` goes to the third, not back to the first. const cl: usize = @intCast(@max(0, pane.cur_row)); const cc: usize = @intCast(@max(0, pane.cur_col)); for (pl.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: output_pane.Origin = switch (kind) { .text => .search, .find => .{ .cmd = .Find }, .grep => .{ .cmd = .Grep }, }; // A different pattern still gets its own buffer: two searches are two // lists, both stay open at their sizes, and the new one stacks directly // below this pane. Everything about landing the rows — which open // buffer counts as this same search, keeping a refill's place, opening // fresh when there is none — is output_pane.fillResults. try output_pane.fillResults(p, id, dir, from, pat, content, anchor); } /// Step to the next/previous row of this pane's results buffer and ACT on /// it — which of the two acme verbs that is comes from the buffer's own /// traits. A location list (every search, every language answer) Looks the /// leading `path:LINE:COL` word; a command list (ThemeSel, FontSel) Execs /// the whole row. False = no live results to step. /// /// n/N used to BE this, and are not any more (lookWalk): stepping a list /// of places now selects and stops, because a step that also opened meant /// you could not walk past a hit without landing on it. What still comes /// through here is what is not n/N at all: `]d`/`[d`, whose whole job is /// to GO to the next diagnostic, and acme's button-3, where clicking a /// word that names nothing searches for it and goes to the first hit. 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 = output_pane.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")); const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; if (step < 0 or step >= nrows) return true; // armed, nowhere left to go const r: i32 = @intCast(step); pane.search_row = @intCast(step); // select the result row in the results pane and keep it in view rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.nsel = 0; rp.cur_row = r; rp.cur_pinned = true; // in view, but WITHOUT scrolloff: a results pane is short, and a // three-row margin on a seven-row one means every single n scrolls the // list out from under the eye. A row already on screen moves nothing. 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); const ln = modal.lineSlice(rf.content, @intCast(step)); var realbuf: [4096]u8 = undefined; const span = if (tr.commands) wholeRowSpan(ln) else look.lookableLineSpan(ln, paneDir(rp), &realbuf); 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; } // Both arms are the BUILTIN, run on the results pane — the same call a // middle or right click on that row would make, so a stepped row and a // clicked row can never drift apart. A command row goes whole (its // argument is the tail after the name); a location row is cut to the // leading file-ish word, since the rest of it is the matched text. if (tr.commands) { p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); } else { var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; p.runBuiltin(config.look_cmd, rid, "", ln[0..hi]); } // the look may focus what it opened — a Find row opens a whole new // file pane every time — so focus comes back to the pane that owns the // search and the next n keeps stepping. A `/` row looks at the // searching pane itself, so this is what already happened there. p.active = id; return true; } /// Ask the backend something about the symbol under the cursor. Only a /// real file can be asked: a terminal's rows are a program's output and an /// output buffer is our own text, neither of which has a language behind /// it. Unsupported kinds never get here (the keymap drops them), so a /// backend that answers nothing simply never opens a buffer. 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; // `status` is about the BACKEND, not about a document, so it answers // from ANY pane — a terminal, a +Search, anywhere. That matters // precisely when the pane you are sitting in is the thing going wrong. // Every other kind needs a real file: a terminal's rows are a // program's output and an output buffer is our own text. 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 and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return; const off = if (pane.file) |f| modal.hxOff(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 }; fn parseLspEdits(p: *Pardes, bytes: []const u8) ?[]LspEdit { if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null; const edits = p.scratch.allocator().alloc(LspEdit, std.mem.count(u8, bytes, "\n")) catch return null; var lines = std.mem.splitScalar(u8, bytes, '\n'); var n: usize = 0; while (lines.next()) |line| { if (line.len == 0) { if (lines.peek() == null) break; return null; } var fields = std.mem.tokenizeScalar(u8, line, ' '); if (!std.mem.eql(u8, fields.next() orelse return null, "@edit")) 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; if (fields.next() != null) return null; edits[n] = .{ .start = start, .end = end }; n += 1; } return if (n == 0) null else edits[0..n]; } fn mapLspEditOffset(edits: []const LspEdit, replacement_len: usize, 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, replacement_len - 1); new_at += replacement_len; if (old == e.end) return new_at; old_at = e.end; } return new_at + (old - old_at); } fn applyLspRename(p: *Pardes, pane: *Pane, revision: u32, new_name: []const u8, bytes: []const u8) void { const f = if (pane.file) |*file| file else return; if (f.revision != revision) return; const edits = p.parseLspEdits(bytes) orelse return; var removed: usize = 0; var previous_end: usize = 0; for (edits) |e| { if (e.start < previous_end or e.start >= e.end or e.end > f.content.len) return; removed = std.math.add(usize, removed, e.end - e.start) catch return; previous_end = e.end; } if (std.mem.eql(u8, f.content[edits[0].start..edits[0].end], new_name)) return; const added = std.math.mul(usize, edits.len, new_name.len) catch return; const final_len = std.math.add(usize, f.content.len - removed, added) catch return; const replacement = p.gpa.alloc(u8, final_len) catch return; const old_cursor = modal.hxOff(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }); const mapped_cursor = mapLspEditOffset(edits, new_name.len, 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 + new_name.len], new_name); write_at += new_name.len; read_at = e.end; } @memcpy(replacement[write_at..], f.content[read_at..]); p.pushUndo(pane); file_pane.setContent(p, f, replacement); const cursor = modal.hxPos(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(); } /// A worker answered. Rename's edit records are consumed first and never /// rendered. Every other response is the ordinary look/output path: /// one row, a goto -> jump straight there (helix jumps on a single /// location and shows a picker on several) /// anything else -> an output buffer, which n/N already steps. That /// buffer IS the picker; there was never one to write. pub fn lspResponse(p: *Pardes, id: u32, rows: []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 pane = p.panes[w.pane] orelse return; if (pane.serial != w.serial) return; if (w.kind == .rename) return p.applyLspRename(pane, w.revision, w.arg.slice(), rows); if (rows.len == 0) { // No rows is a legal answer everywhere except here. Tab DIVERTED // instead of indenting, so an empty answer would eat the keystroke // — a dot in a comment, a dot in a string, a half-typed line // nothing can be made of — and a Tab that silently does nothing is // worse than not having the feature. So the indent happens now, // late, on the condition that nothing has moved: same pane, still // in insert, one cursor, and the cursor still on the cell the Tab // was pressed at. Anyone who kept typing during the query gets // nothing rather than four spaces landing 300ms behind their hands. if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and pane.cur_row == w.row and pane.cur_col == w.col) p.insertTab(pane); return; } const from: output_pane.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); if (output_pane.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; // exactly what a `/` result row does when n steps onto it: look the // `path:LINE:COL` token from the pane that asked, so placement, // dedup-onto-an-open-pane and centering are the ONE look path. // (helix would also push its jumplist here; pardes has none, so // there is nothing to push — do not read this as one.) return p.lookAt(w.pane, ln[0..hi]); } 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; // Landing the rows is runSearch's path exactly, keyed on the KIND // rather than the argument (fillResults reads that off the origin). // Why the refill is not optional here: docs/lsp.md. output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| p.reportError(w.pane, "language response", err); } // ---- n/N: the walk over look-able text ---- /// How many rows ONE PRESS may scan, across every pane it visits. A /// shell's motion surface is its whole scrollback and every whitespace run /// on it costs a realpath, so the walk is bounded. /// /// Running out STOPS the walk where it stands rather than treating the /// pane as exhausted and moving on, and that distinction is load-bearing: /// giving up in the middle of a pane and hopping to the next one would /// make the two directions disagree about where a pane ENDS, and n/N have /// to be exact inverses. Not moving is the one failure that always is. /// A pane whose next look-able text is eight thousand rows away is a pane /// to scroll, not to step. const max_look_rows = 8192; /// Where a step STARTS inside a pane. `col` null enters the pane at the /// row's edge — every span on it is ahead of you — which is what a hop /// from a neighbouring pane does. `strict` says the column is a position /// the walk itself established, so the span sitting ON it is the one you /// are already at and the step must go past it. const LookFrom = struct { row: i32, col: ?i32, strict: bool = false }; /// Entering a pane from a neighbour: the first row going forward, the last /// going back. Spelled once because it is exactly what makes the two /// directions inverses across a pane boundary. fn lookEdge(delta: i32) LookFrom { return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null }; } /// Where the walk currently stands in `pane`. /// /// `Pane.look_at` and not the cursor alone, because the cursor cannot /// answer the question. A cursor parked on the first look-able span may /// mean the walk put it there — so the next step is the SECOND span — or /// that the pane simply opened that way, which is every fresh +Search, and /// there the next step must be the FIRST. `search_row` answered the same /// question the same way for the same reason. When the recorded stand no /// longer matches the cursor you have moved it yourself since, and the /// cursor wins: the walk continues from where you are looking. 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 }; } /// The panes n/N walk, in the order it walks them: every pane that has /// performed a LOOK, most recent first, then the OUTPUT buffers none has, /// newest first — and, only when that comes to nothing at all, the pane /// you are in. /// /// The look history is the spine because looking is what marks a pane as /// the one you are reading things OUT of — the +Search you are stepping, /// the diagnostics list, the shell whose `ls` rows you keep opening. The /// unlooked output buffers come after it so a fresh `/`, which has looked /// at nothing yet, still has somewhere for the first `n` to go: its own /// results. FILO among them, so two searches step the newer list first. /// /// The ACTIVE pane is the fallback and NOT a member, which is the /// difference between n continuing a list and n wandering off it. Look a /// row out of a +Search and focus lands in the file that opened; the next /// n has to go back to the +Search, not start walking the paths that /// happen to be in the source you just opened. Only when nothing has /// looked and no buffer has answered — a shell one minute into a session, /// which is where n/N started life — is the pane in front of you the list. 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]; } /// Is a span starting at `col0` PAST `from` in the direction of travel? /// Only the row a walk STARTED on is filtered — every span on a row it /// arrived at is ahead of it — and the comparison is against `col0` rather /// than the whitespace run's start. Those are different columns the moment /// a wrapper is peeled: `(mise.toml)` is a run starting at 0 and a span /// starting at 1, and a backward step filtered on the run would find the /// span it is standing on still ahead of it and never leave the row. 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; } /// The whole row as one span, first non-blank cell to last — the `.whole` /// grain. The trailing trim keeps a padded row selecting the command and /// not the padding. 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; } /// The next STEPPABLE span in `pane` from `from`, in `delta`'s direction, /// or null when the pane has none left that way. `budget` is the caller's /// remaining row allowance and is spent here; a null return with a budget /// of zero means GAVE UP, not exhausted (see max_look_rows). /// /// WHAT A SPAN IS comes from the pane's grain (output_pane.Grain) and is /// the one thing about this motion a buffer gets to change: /// .word free text — a terminal, a file, a PDF — where a row may hold /// several places and every look-able run is a stop: an `ls` /// line hops big.txt -> plain.txt -> sub (look.lookableSpan). /// .line a results buffer, where a row IS one location: one stop per /// row, on the largest run its head resolves as, and the matched /// text after it is not a second stop (look.lookableLineSpan). /// .whole a command list (ThemeSel, FontSel), where the line is the /// word: `Theme gruvbox` has no path inside it to pick out. /// Same motion, same selection, same Enter/Tab afterwards. /// /// Symmetric by construction in all three, and that is the whole point: /// both directions ask the same question about the same rows, and both /// compare against `col0` — the column the walk parks the cursor on. So a /// step forward off a span and a step back onto it are the same two /// positions read in the two orders. fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?LookSpot { const pl = p.paneCursorLines(pane) catch return null; const nrows: i32 = @intCast(pl.lines.len); if (nrows == 0) return null; const grain: output_pane.Grain = if (pane.file) |*f| output_pane.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 = pl.lines[@intCast(r)]; const on_start = r == start; switch (grain) { .word => { var best: ?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.lookableSpan(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 = (if (grain == .line) look.lookableLineSpan(ln, dir, &realbuf) else wholeRowSpan(ln)) 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; } /// n/N: move the SELECTION to the next/previous look-able text and open /// NOTHING. Enter looks what this leaves selected, and that separation is /// the change: a step is a motion you can take twenty of and then decide, /// where it used to be twenty panes. /// /// The sequence stepped is the concatenation, in lookWalkPanes' order, of /// each pane's look-able spans in document order, AND IT IS A RING. `n` is /// the next position on that ring and `N` the previous one, computed the /// same way from the same state — so x presses one way and x back land /// exactly where you started, across pane boundaries included: a pane /// entered forward is entered at its FIRST span, and leaving it backward /// from that span drops into the previous pane's LAST. /// /// A ring rather than a list with two ends, for two reasons that turn out /// to be one. A shell's cursor sits at the PROMPT, below everything it has /// printed, so a walk that could not come round would have nowhere to go /// on the very first press — which is the case n/N was written for. And a /// ring is still exactly reversible, so nothing is given up for it: acme's /// search has always been one, and this is that. /// /// (One press is not symmetric, and cannot be: from a cursor the walk has /// never stood on, the first step ACQUIRES a position rather than moving /// one — see lookStand. Every press after that is exact.) /// /// Position stays in each pane (`look_at`); the one global serial records /// only WHICH stream owns the next step after a Look moves focus away. /// Serials make deletion/reuse stale safely, and refilling a list simply /// re-arms that list without manufacturing a second cursor. /// /// ONE MOTION, EVERYWHERE. Not a pane kind, not a buffer kind, not a mode: /// n/N are this walk in all of them, which is the other half of making /// them trustworthy. A PDF used to step its results buffer and jump; it /// steps the same ring now, which IS that buffer, and Enter does the /// jumping. The single thing any buffer gets to change is the GRAIN of /// what a step selects, and it changes it by BEING a kind of buffer rather /// than by a branch here (output_pane.Grain, read in lookSpanIn): free /// text steps every look-able word, a results list steps one ROW at a time /// — its head is the location and the rest is the match — and a command /// list steps the whole line, because a ThemeSel row is a word to run and /// not a place to go. /// /// `]d`/`[d` are not n/N. They are helix's diagnostic motions, their job /// is to ARRIVE at the next diagnostic, and they still reach searchStep. fn lookWalk(p: *Pardes, delta: i32) void { var buf: [MAX_PANES]usize = undefined; const order = p.lookWalkPanes(&buf); if (order.len == 0) return; // A Look may move focus away from the list it came from. Continue the // explicitly armed origin first; only a stale/missing owner falls back // to the pane under focus and then the history head. 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); // ...then every OTHER pane once, in the direction of travel, entered // at its edge — and `k == order.len` brings the starting pane round a // second time, from ITS edge, which is the wrap. That bound is also // what makes a screen with nothing look-able on it terminate. 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); } } /// Select `spot` and focus its pane. The selection is EXPLICIT so Enter's /// look chord acts on it, with the anchor on the span's last cell and the /// cursor on its FIRST — the same shape the old terminal stepper left, and /// the reason `col0` is the position the walk compares against. fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: LookSpot) void { p.pinPaneCursor(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(); } /// Route shared edit operations to a file's content or a terminal overlay. fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null or hasPdf(pane)) return null; return term_pane.editText(p, pane, lo, hi, col); } /// editText for an op whose selection can END on a line's newline cell: /// eating that newline joins with the line BELOW, so a terminal's buffer /// has to cover that row too (a file's content always already does). fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?EditText { const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return null; const r: usize = @intCast(@max(0, b.hi_row - eb.row0)); if (r + 1 < modal.lineCount(eb.text)) return eb; if (b.hi_col < @as(i32, @intCast(modal.lineSlice(eb.text, r).len))) return eb; return p.editText(pane, b.lo_row, b.hi_row + 1, -1); } /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { if (pane.file) |*f| return file_pane.setContent(p, f, new); term_pane.setEditText(p, pane, new); } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { if (hasPdf(pane)) return; p.pinPaneCursor(pane); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected p.pushUndo(pane); pane.select = false; pane.append_at = null; pane.sticky_col = -1; const pl = p.paneCursorLines(pane) catch { pane.mode = .insert; pane.msel.active = false; pane.vsel.active = false; pane.pending = 0; return; }; const text = p.flatSurface(pane, pl) catch return; const cur = toModalCursor(pane, pl); const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; // helix selection-aware entry: `i` to the selection's START (the // selection flips and survives until the first edit); `a` one past // its END, remembering the origin cell for the Esc restore const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null; switch (where) { .at => { if (b) |bb| { pane.cur_row = bb.lo_row; pane.cur_col = bb.lo_col; pane.vsel = .{ .active = true, .row = bb.hi_row, .col = bb.hi_col, .explicit = false }; pane.cur_pinned = true; } }, .append => { const lo_row = if (b) |bb| bb.lo_row else pane.cur_row; const lo_col = if (b) |bb| bb.lo_col else pane.cur_col; const hi_row = if (b) |bb| bb.hi_row else pane.cur_row; const hi_col = if (b) |bb| bb.hi_col else pane.cur_col; pane.append_at = .{ .row = lo_row, .col = lo_col }; const gap = modal.nextGrapheme(text, modal.hxOff(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); const gc = modal.hxPos(text, gap); pane.cur_row = @intCast(gc.row); pane.cur_col = @intCast(gc.col); pane.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; pane.cur_pinned = true; }, .line_start => { fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)); pane.vsel.active = false; }, .line_end => { pane.cur_col = @intCast(llen); pane.cur_pinned = true; pane.vsel.active = false; }, .open_below, .open_above => { pane.vsel.active = false; const below = where == .open_below; const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row; const eb = p.editText(pane, abs, abs, -1) orelse return; const row: usize = @intCast(@max(0, abs - eb.row0)); const ind = modal.hxIndentString(modal.lineSlice(eb.text, row)); const arena = p.scratch.allocator(); const block_len = std.math.mul(usize, cnt, ind.len + 1) catch return; const block = arena.alloc(u8, block_len) catch return; var block_at: usize = 0; for (0..cnt) |_| { if (below) { block[block_at] = '\n'; block_at += 1; } @memcpy(block[block_at..][0..ind.len], ind); block_at += ind.len; if (!below) { block[block_at] = '\n'; block_at += 1; } } const at: modal.Cursor = if (below) .{ .row = row, .col = modal.lineSlice(eb.text, row).len } else .{ .row = row, .col = 0 }; const new = modal.insertAt(p.gpa, eb.text, at, block) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; if (cnt > 1 and !p.multi_on) opened: { const pl2 = p.paneCursorLines(pane) catch break :opened; const t2 = p.flatSurface(pane, pl2) catch break :opened; var rs: [MAX_SELS]modal.HxRange = undefined; const m = @min(cnt, MAX_SELS); for (0..m) |k| { const o = modal.hxOff(t2, .{ .row = @intCast(@max(0, pane.cur_row - pl2.row0) + @as(i32, @intCast(k))), .col = ind.len }); rs[k] = .{ .anchor = o, .head = o }; } setPaneRanges(pane, pl2, t2, rs[0..m], &.{}, 0, false); } }, } pane.mode = .insert; pane.msel.active = false; pane.pending = 0; pane.ensureCursorVisible(); } /// insert mode. ONE path for both pane kinds: a file edits its content, a /// terminal edits the buffer standing in for its shell rows (editText /// materializes and grows it), so typing, Enter, joins and the kill runs /// mean exactly the same thing in a shell pane as in a document. fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { if (pane.nsel == 0) return p.insertKey(pane, key); p.replaySels(pane, .{ .insert = key }); } fn insertKey(p: *Pardes, pane: *Pane, key: Key) void { // helix aliases: normalize to the base key and re-dispatch if (hit(key, config.insert_backspace_alias)) return p.insertKey(pane, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return p.insertKey(pane, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete }); // a selection carried into insert (i/a) survives only until the next // key: helix maps it through every edit, pardes drops it instead — // its only pardes use (the acme chords) needs explicit selections // anyway, and those never enter insert mode pane.vsel.active = false; if (!pane.cur_pinned) p.pinPaneCursor(pane); // arrows and paging are pure motion over the WHOLE surface, so they // run before editText — a terminal must not freeze shell rows into an // edit buffer just because you walked across them switch (key.cp) { Key.left, Key.right, Key.up, Key.down => { const pl = p.paneCursorLines(pane) catch return; const cur0 = toModalCursor(pane, pl); const nc = switch (key.cp) { Key.left => modal.charLeft(pl.lines, cur0), Key.right => modal.charRight(pl.lines, cur0), Key.up => insertVerticalCursor(pl.lines, cur0, false), Key.down => insertVerticalCursor(pl.lines, cur0, true), else => cur0, }; fromModalCursor(pane, pl, nc); pane.ensureCursorVisible(); return; }, Key.page_up, Key.page_down => { // helix binds insert pageup/pagedown to the same view // scroll + cursor snap as normal mode const pl = p.paneCursorLines(pane) catch return; const flat = p.flatSurface(pane, pl) catch return; const range = paneRange(pane, flat, pl.row0); const step: i32 = @intCast(@max(1, pane.rows)); scrollViewMove(pane, pl, flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; }, Key.end => { // helix insert End: past the last char (goto_line_end_newline) const pl = p.paneCursorLines(pane) catch return; const cur0 = toModalCursor(pane, pl); pane.cur_col = @intCast(if (cur0.row < pl.lines.len) pl.lines[cur0.row].len else 0); pane.cur_pinned = true; pane.ensureCursorVisible(); return; }, else => {}, } const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return; const text = eb.text; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; if (key.text.len > 0 and !key.ctrl and !key.alt) { const new = modal.insertAt(p.gpa, text, c, key.text) catch return; p.setEditText(pane, new); for (key.text) |ch| { if (ch == '\n') { pane.cur_row += 1; pane.cur_col = 0; } else pane.cur_col += 1; } pane.cur_pinned = true; pane.ensureCursorVisible(); return; } // helix insert-mode kills (word/line; deleteSpan is exclusive) if (hit(key, config.delete_word_backward)) { // helix delete_word_backward: to the previous word start — // crossing the newline at col 0, which takes the word before it // too, so on the buffer's first line a terminal grows up one row const e2 = if (c.row == 0 and c.col == 0) (p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; const g = modal.hxOff(e2.text, c2); if (g == 0) return; const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); const from = @min(wr.anchor, wr.head); const fc = modal.hxPos(e2.text, from); const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; pane.cur_col = @intCast(fc.col); pane.cur_pinned = true; pane.ensureCursorVisible(); return; } if (hit(key, config.delete_word_forward)) { // helix delete_word_forward: to the next word END (trailing // whitespace survives), crossing newlines at line ends — at the // buffer's last line a terminal grows down one row to allow it const e2 = if (c.col >= modal.lineSlice(text, c.row).len and c.row + 1 >= modal.lineCount(text)) (p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; const g = modal.hxOff(e2.text, c2); const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end); const to = @max(wr.anchor, wr.head); if (to <= g) return; const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.hxPos(e2.text, to)) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; } if (hit(key, config.kill_to_line_start)) { const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return; p.setEditText(pane, new); pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; } if (hit(key, config.kill_to_line_end)) { const llen = modal.lineSlice(text, c.row).len; const new = modal.deleteSpan(p.gpa, text, c, .{ .row = c.row, .col = llen }) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; } switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); const indent = modal.hxNewlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @memset(block[1..], ' '); const new = modal.insertAt(p.gpa, text, c, block) catch return; p.setEditText(pane, new); pane.cur_row += 1; pane.cur_col = @intCast(indent); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.backspace => { if (pane.cur_col > 0) { const line = modal.lineSlice(text, c.row); const prev = modal.prevGrapheme(line, c.col); const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return; p.setEditText(pane, new); pane.cur_col = @intCast(prev); pane.cur_pinned = true; pane.ensureCursorVisible(); return; } // at col 0: eat the newline above (join up) — a terminal grows // its buffer over the row it is joining with if (pane.cur_row <= 0) return; const e2 = p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return; const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); if (row == 0) return; const prevlen = modal.lineSlice(e2.text, row - 1).len; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row - 1, .col = prevlen }, .{ .row = row, .col = 0 }) catch return; p.setEditText(pane, new); pane.cur_row -= 1; pane.cur_col = @intCast(prevlen); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.delete => { // delete forward; at line end eat the newline (join), which on // a terminal means the buffer swallowing the row below const line = modal.lineSlice(text, c.row); if (c.col < line.len) { const new = modal.deleteChar(p.gpa, text, c) catch return; p.setEditText(pane, new); } else { const e2 = p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return; const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); if (row + 1 >= modal.lineCount(e2.text)) return; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row, .col = c.col }, .{ .row = row + 1, .col = 0 }) catch return; p.setEditText(pane, new); } pane.cur_pinned = true; }, Key.tab => { // Tab straight after a `.` asks the language backend what // could go there — an output buffer of DEFINITIONS, one row // per candidate, not an autocomplete popup and not an // insertion. Only where an answer is possible: a terminal, an // output buffer or a file the backend does not speak still // indents, because a Tab that silently does nothing is worse // than not having the feature. The extension list stays the // backend's (lsp.speaks); this only asks. (An answer that // comes back EMPTY indents too, late — see lspResponse.) // // Never with several cursors. A language query is a // per-KEYSTROKE action inside a per-SELECTION replay, so // multiOnce would stop the replay dead: the other cursors // would neither ask nor indent and the whole multicursor would // collapse on a Tab. Every other insert key applies to all of // them, and so does this one — by indenting. const ln = modal.lineSlice(text, c.row); if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: { const f = pane.file orelse break :dot; if (f.output != null or !lsp.speaks(f.path)) break :dot; // speaks() is the fast path only — lspRequest has four // bails of its own (unsupported kind, dead pane, output // buffer, multiOnce) and each one would eat the Tab. The // seq bump is the one honest "the question went out", so // ask and fall through to the indent if it did not. const seq = p.lsp_seq; p.lspRequest(p.active, .completion, ""); if (p.lsp_seq != seq) return; } p.insertTab(pane); }, else => {}, } } /// helix insert_tab with a Spaces indent style: spaces to the next tab /// stop (smart-tab machinery skipped). A function because lspResponse /// presses the same key, a turn of the loop later. fn insertTab(p: *Pardes, pane: *Pane) void { const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return; const c: modal.Cursor = .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)), }; const pad = modal.INDENT_W - (c.col % modal.INDENT_W); const new = modal.insertAt(p.gpa, eb.text, c, " "[0..pad]) catch return; p.setEditText(pane, new); pane.cur_col += @intCast(pad); pane.cur_pinned = true; pane.ensureCursorVisible(); } const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; /// a range's two cells, normalized to document order fn cellBounds(s: SelRange) Bounds { if (s.row < s.arow or (s.row == s.arow and s.col < s.acol)) return .{ .lo_row = s.row, .lo_col = s.col, .hi_row = s.arow, .hi_col = s.acol }; return .{ .lo_row = s.arow, .lo_col = s.acol, .hi_row = s.row, .hi_col = s.col }; } /// the char selection [anchor, cursor] normalized to document order fn vselBounds(pane: *Pane) Bounds { return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col }); } /// the char selection as text. Read off the pane's SURFACE (file content / /// terminal shell rows + edit buffer), not the rendered body: a yank of a /// whole line has to carry its newline, the way a file's does, or p/P /// paste it charwise. Scratch-owned. fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); const text = if (pane.file) |f| f.content else surface: { const pl = p.paneCursorLines(pane) catch return ""; break :surface p.flatSurface(pane, pl) catch return ""; }; return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch ""; } /// join surface rows [r0, r1] (absolute) with '\n'; scratch-owned fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 { const arena = p.scratch.allocator(); const pl = p.paneCursorLines(pane) catch return ""; const rows_count: usize = @intCast(@max(0, r1 - r0 + 1)); var total: usize = rows_count -| 1; var i = r0; while (i <= r1) : (i += 1) { if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) total += pl.lines[@intCast(i)].len; } const out = arena.alloc(u8, total) catch return ""; var at: usize = 0; i = r0; while (i <= r1) : (i += 1) { if (i > r0) { out[at] = '\n'; at += 1; } if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) { const line = pl.lines[@intCast(i)]; @memcpy(out[at..][0..line.len], line); at += line.len; } } return out; } // ---- edit operations (d / c / y / p) ---- fn normalDelete(p: *Pardes, pane: *Pane, yank: bool) void { pane.select = false; // helix delete exits select mode pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); if (yank) p.setYank(p.vselText(pane)); // read the range before the move p.pushUndo(pane); const eb = p.editTextEol(pane, b) orelse { pane.vsel.active = false; return; }; const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { pane.vsel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; clampCursor(pane, d.content, eb.row0); return; } if (pane.msel.active) { const r0 = @min(pane.msel.r0, pane.msel.r1); const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); // linewise: a line's newline lives on the row below it const eb = p.editText(pane, r0, r1 + 1, -1) orelse { pane.msel.active = false; return; }; const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, r0 - eb.row0)), @intCast(@max(0, r1 - eb.row0))) catch { pane.msel.active = false; return; }; p.setEditText(pane, d.content); if (yank) p.setYank(d.deleted); p.gpa.free(d.deleted); const n = modal.lineCount(d.content); pane.cur_row = @as(i32, @intCast(@min(@as(usize, @intCast(@max(0, r0 - eb.row0))), if (n == 0) 0 else n - 1))) + eb.row0; pane.cur_col = 0; pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } else { p.pushUndo(pane); const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; if (yank) { const arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, eb.text, c, c) catch return); } // deleteRange (not deleteChar): the cursor may sit ON a '\n' // cell, where a delete joins the lines (helix 1-wide selection) const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); } } fn normalYank(p: *Pardes, pane: *Pane) void { pane.select = false; if (pane.vsel.active) { p.setYank(p.vselText(pane)); return; } if (pane.msel.active) { p.setYank(p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1))); } else { const save = pane.vsel; pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = false }; p.setYank(p.vselText(pane)); pane.vsel = save; } } /// helix p/P: the DEFAULT register, after/before the selection. fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { p.pasteText(pane, p.yank orelse return, before); } /// ...and the paste itself, over text from wherever: the register above, /// or the system clipboard `SPC p` asked the shell for, which deliberately /// never passes through the register on its way here. /// /// Text ending in '\n' pastes as whole lines below/above the SELECTION's /// line span; anything else splices inline at the selection's outer edge. /// The paste (repeated times) becomes the implicit selection, /// cursor on its last char (linewise: ON the last pasted line's newline). fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool) void { if (y0.len == 0) return; p.pushUndo(pane); pane.select = false; pane.sticky_col = -1; const cnt: usize = @max(1, pane.count); pane.count = 0; const arena = p.scratch.allocator(); var y: []const u8 = y0; if (cnt > 1) { const total = std.math.mul(usize, cnt, y0.len) catch return; const buf = arena.alloc(u8, total) catch return; for (0..cnt) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); y = buf; } const b: Bounds = if (pane.vsel.active) vselBounds(pane) else .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return; const row0 = eb.row0; if (y[y.len - 1] == '\n') { const block_text = y[0 .. y.len - 1]; // a yanked BLANK line is "\n": the block is empty and lineCount // says 0 lines, but it still pastes as one (empty) line — without // the floor every `n - 1` below underflows and panics. const n = @max(1, modal.lineCount(block_text)); var out: []u8 = undefined; if (before) { const row: usize = @intCast(@max(0, b.lo_row - row0)); const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return; out = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = 0 }, block) catch return; pane.cur_row = @as(i32, @intCast(row + n - 1)) + row0; } else { const row: usize = @intCast(@max(0, b.hi_row - row0)); out = modal.pasteLineBelow(p.gpa, eb.text, row, block_text) catch return; pane.cur_row = @as(i32, @intCast(row + n)) + row0; } p.setEditText(pane, out); pane.vsel = .{ .active = true, .row = pane.cur_row - @as(i32, @intCast(n - 1)), .col = 0, .explicit = false }; // cursor ON the last pasted line's '\n' (helix) const llen = modal.lineSlice(out, @intCast(@max(0, pane.cur_row - row0))).len; pane.cur_col = @intCast(llen); } else { const at: modal.Cursor = if (before) .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) } else blk: { const hrow: usize = @intCast(@max(0, b.hi_row - row0)); const hi_col: usize = @intCast(@max(0, b.hi_col)); const high_line = modal.lineSlice(eb.text, hrow); if (hi_col >= high_line.len) break :blk .{ .row = hrow + 1, .col = 0 }; const gcol = modal.nextGrapheme(high_line, hi_col); break :blk .{ .row = hrow, .col = gcol }; }; const out = modal.insertAt(p.gpa, eb.text, at, y) catch return; p.setEditText(pane, out); pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(at.row)) + row0, .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { pane.cur_row = @as(i32, @intCast(end.row)) + row0; pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(out, end.row), end.col)); } else { pane.cur_row = @as(i32, @intCast(end.row -| 1)) + row0; pane.cur_col = @intCast(modal.lineSlice(out, end.row -| 1).len); } } pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } fn normalChange(p: *Pardes, pane: *Pane) void { pane.select = false; pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); const eb = p.editTextEol(pane, b) orelse return; const hi_len = modal.lineSlice(eb.text, @intCast(@max(0, b.hi_row - eb.row0))).len; if (b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len) { p.setYank(p.vselText(pane)); p.pushUndo(pane); const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, b.lo_row - eb.row0)), @intCast(@max(0, b.hi_row - eb.row0))) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const n = modal.lineCount(d.content); const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row - eb.row0))), if (n == 0) 0 else n - 1); const ind = modal.hxIndentString(modal.lineSlice(d.content, row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; const new = modal.insertAt(p.gpa, d.content, .{ .row = row, .col = 0 }, block) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.vsel.active = false; pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; pane.pending = 0; pane.ensureCursorVisible(); return; } p.setYank(p.vselText(pane)); p.pushUndo(pane); const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { pane.vsel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; clampCursor(pane, d.content, eb.row0); pane.mode = .insert; pane.pending = 0; return; } if (pane.msel.active) { const r0 = @min(pane.msel.r0, pane.msel.r1); const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); const eb = p.editText(pane, r0, r1 + 1, -1) orelse return; const arena = p.scratch.allocator(); const lo: usize = @intCast(@max(0, r0 - eb.row0)); const hi: usize = @intCast(@max(0, r1 - eb.row0)); var total: usize = hi - lo; for (lo..hi + 1) |r| total += modal.lineSlice(eb.text, r).len; const buf = arena.alloc(u8, total) catch return; var at: usize = 0; for (lo..hi + 1) |r| { if (r > lo) { buf[at] = '\n'; at += 1; } const line = modal.lineSlice(eb.text, r); @memcpy(buf[at..][0..line.len], line); at += line.len; } p.setYank(buf); var body: []u8 = eb.text; if (hi > lo) { const d = modal.deleteLines(p.gpa, body, lo + 1, hi) catch { pane.msel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); body = d.content; } const cl = modal.clearLine(p.gpa, body, lo) catch { pane.msel.active = false; return; }; p.setEditText(pane, cl); pane.cur_row = r0; pane.cur_col = 0; pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; pane.pending = 0; pane.ensureCursorVisible(); } else { p.pushUndo(pane); const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; const arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, eb.text, c, c) catch return); const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; pane.pending = 0; } } // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- // Terminals go through the same edit buffer as files: since editText // materializes one over whatever rows the op names, every one of these // works the same in a shell pane as in a document. /// the selection as an inclusive cursor range in `content`, whose first /// line is absolute row `row0`: vsel span, msel line span, else the char /// under the cursor (helix's implicit 1-wide selection) fn selRange(pane: *Pane, content: []const u8, row0: i32) modal.Range { if (pane.vsel.active) { const b = vselBounds(pane); return .{ .a = .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) }, .b = .{ .row = @intCast(@max(0, b.hi_row - row0)), .col = @intCast(@max(0, b.hi_col)) }, }; } if (pane.msel.active) { const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); const llen = modal.lineSlice(content, r1).len; return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else modal.prevGrapheme(modal.lineSlice(content, r1), llen) } }; } const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }; return .{ .a = c, .b = c }; } /// the rows an op's selection spans, absolute (what editText must cover) fn selRows(pane: *Pane) Bounds { if (pane.vsel.active) return vselBounds(pane); if (pane.msel.active) return .{ .lo_row = @min(pane.msel.r0, pane.msel.r1), .lo_col = 0, .hi_row = @max(pane.msel.r0, pane.msel.r1), .hi_col = 0, }; return .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; } /// `r`: overwrite the selection (or the cursor char) with ch — /// newlines included (helix), so `xrz` joins the selected lines fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u21) void { pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const before = paneRange(pane, eb.text, eb.row0); const lo = @min(before.anchor, before.head); const hi = @max(before.anchor, before.head); var graphemes: usize = 0; var at = lo; while (at < hi) : (graphemes += 1) at = modal.nextGrapheme(eb.text, at); var encoded: [4]u8 = undefined; const encoded_len = std.unicode.utf8Encode(ch, &encoded) catch return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; p.setEditText(pane, new); const end = lo + graphemes * encoded_len; const mapped: modal.HxRange = if (before.head < before.anchor) .{ .anchor = end, .head = lo } else .{ .anchor = lo, .head = end }; setPaneRange(pane, .{ .lines = &.{}, .row0 = eb.row0 }, new, mapped, pane.vsel.explicit); } /// `R`: replace the selection (or the cursor char) with the DEFAULT /// register. `SPC R` is the same verb over the system clipboard. fn normalReplaceYank(p: *Pardes, pane: *Pane) void { p.replaceWithText(pane, p.yank orelse return); } /// The pasted text becomes the selection, head on its last char (which for /// a trailing-newline `y` is the '\n' cell of the last full line). fn replaceWithText(p: *Pardes, pane: *Pane, y: []const u8) void { if (y.len == 0) return; pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return; p.setEditText(pane, new); pane.msel.active = false; pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; const end = modal.advanceBy(r.a, y); if (end.col > 0) { pane.cur_row = @as(i32, @intCast(end.row)) + eb.row0; pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col)); } else { // the yank ended in '\n': the cursor lands ON that newline pane.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; pane.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char), /// keeping the selection (helix) fn normalCase(p: *Pardes, pane: *Pane, op: modal.CaseOp) void { pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.changeCase(p.gpa, eb.text, r.a, r.b, op) catch return; p.setEditText(pane, new); } /// a change to apply to file content: delete [from, to), insert `ins` at /// `from`. Ascending and disjoint. const TextChange = struct { from: usize, to: usize, ins: []const u8 }; /// map an original-text offset through a change list (insertions AT a /// position push it right — helix Assoc::After; positions inside a /// deleted span collapse to its start) fn mapThroughChanges(chs: []const TextChange, pos: usize) usize { var delta: i64 = 0; for (chs) |ch| { if (pos < ch.from) break; if (ch.from == ch.to) { delta += @intCast(ch.ins.len); continue; } if (pos == ch.from) break; if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta); delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); } return @intCast(@as(i64, @intCast(pos)) + delta); } /// apply a change list. gpa-owned result. fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { var total = text.len; for (chs) |ch| total = total - (ch.to - ch.from) + ch.ins.len; const out = try p.gpa.alloc(u8, total); errdefer p.gpa.free(out); var copied: usize = 0; var at: usize = 0; for (chs) |ch| { const unchanged = text[copied..ch.from]; @memcpy(out[at..][0..unchanged.len], unchanged); at += unchanged.len; @memcpy(out[at..][0..ch.ins.len], ch.ins); at += ch.ins.len; copied = ch.to; } @memcpy(out[at..], text[copied..]); return out; } /// `J`: helix join_selections — join the selection's line span (a bare /// cursor joins with the next line): each '\n' + following indent become /// one space, EXCEPT before content-less lines (no space) — and on the /// buffer's last line the trailing newline is deleted. The selection and /// cursor map through the edit; the count is ignored (helix). fn normalJoin(p: *Pardes, pane: *Pane) void { // a join always eats the newline of its last line, so the buffer has // to reach one row PAST the selection const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row + 1, -1) orelse return; const text = eb.text; const range = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const nlines = modal.hxLineCount(text); var end = span.end; if (span.start == end) end = @min(end + 1, nlines - 1); if (end <= span.start) return; const arena = p.scratch.allocator(); const chs = arena.alloc(TextChange, end - span.start) catch return; var chs_len: usize = 0; var l = span.start; while (l < end) : (l += 1) { const from = modal.hxLineEndIdx(text, l); var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; const sep: []const u8 = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " "; chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; chs_len += 1; } if (chs_len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `>` / `<`: helix indent/unindent over the selection's line span. /// Blank (all-whitespace) lines are skipped; `>` inserts count levels /// realigned to the next INDENT_W stop; `<` removes up to count levels of /// leading whitespace (a tab advances to the next stop). Cursor and /// selection map through the edit. fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void { pane.select = false; const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row, -1) orelse return; const text = eb.text; const range = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const arena = p.scratch.allocator(); // one run of spaces, sliced per line: `>` never inserts more than this const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return; @memset(pad, ' '); const chs = arena.alloc(TextChange, span.end - span.start + 1) catch return; var chs_len: usize = 0; var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); const le = modal.hxLineEndIdx(text, l); const line = text[ls..le]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; // blank lines stay blank (helix) if (add) { const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W); chs[chs_len] = .{ .from = ls, .to = ls, .ins = pad[0..ins] }; chs_len += 1; } else { const want = modal.INDENT_W * cnt; var w: usize = 0; var pos: usize = 0; while (pos < line.len) { if (line[pos] == ' ') { w += 1; } else if (line[pos] == '\t') { w = (w / modal.INDENT_W + 1) * modal.INDENT_W; } else break; pos += 1; if (w >= want) break; } if (pos > 0) { chs[chs_len] = .{ .from = ls, .to = ls + pos, .ins = "" }; chs_len += 1; } } } if (chs_len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `Ctrl-c`: helix toggle_comments. Every line the selection touches gets /// the language's line-comment token put in front of it — or taken off, /// and WHICH of the two is decided once for the whole set: one uncommented /// non-blank line among them and everything gets commented. That single /// decision is why Action.scope runs this once instead of per cursor; /// replayed, a half-commented block would end up half-commented the other /// way round. /// /// The rest is helix's find_line_comment, quirks included: the token goes /// in at the SHALLOWEST indent in the set (so a deeper line is commented /// mid-whitespace), all-blank lines are skipped entirely and do not vote, /// and uncommenting also eats one space after the token unless some line /// lacks it. fn normalToggleComment(p: *Pardes, pane: *Pane) void { const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; pane.select = false; // helix exit_select_mode // the buffer must cover every cursor's rows, not just the primary's var b = selRows(pane); for (pane.sels[0..pane.nsel]) |s| { b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); } // ...and ONE ROW PAST them, like normalJoin: a selection may end on // its last line's newline cell, and a terminal buffer that stops at // that line has nowhere to put it (a file's content always does). // Never past the surface's own last row, though — materialising a row // that does not exist yet would ADD a blank line to the pane, and this // op may well decide to change nothing. const pl0 = p.paneCursorLines(pane) catch return; const last_row = pl0.row0 + @as(i32, @intCast(pl0.lines.len)) - 1; const eb = p.editText(pane, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return; const text = eb.text; var rs: [MAX_SELS]modal.HxRange = undefined; const got = paneRanges(pane, text, eb.row0, &rs); const nlines = modal.hxLineCount(text); const arena = p.scratch.allocator(); // the lines the ranges cover, each ONE ONCE and in order (helix's // min_next_line: two cursors on one line comment it once) const lines = arena.alloc(usize, nlines) catch return; var lines_len: usize = 0; var next: usize = 0; for (rs[0..got.n]) |r| { const span = rangeLineSpan(text, r); var l = @max(span.start, next); const end = @min(span.end + 1, nlines); while (l < end) : (l += 1) { lines[lines_len] = l; lines_len += 1; } next = @max(next, end); } // which token: the file's EXTENSION, which is the same thing // src/syntax.zig tells languages apart by, read off the one table in // config. A terminal and an output buffer have no extension and get // the default, which is what helix does for a buffer with no language. const ext = if (pane.file) |f| std.fs.path.extension(f.path) else ""; var token: []const u8 = config.comment_token_default; lang: for (config.comment_tokens) |row| { for (row.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) { token = row.token; break :lang; }; } var commented = true; var indent: usize = std.math.maxInt(usize); var margin: usize = 1; var live: usize = 0; for (lines[0..lines_len]) |l| { const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(text, l)]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; indent = @min(indent, nw); if (!std.mem.startsWith(u8, line[nw..], token)) commented = false; if (nw + token.len >= line.len or line[nw + token.len] != ' ') margin = 0; live += 1; } if (live == 0) return; // nothing but blank lines const ins = std.fmt.allocPrint(arena, "{s} ", .{token}) catch return; const chs = arena.alloc(TextChange, lines_len) catch return; var chs_len: usize = 0; for (lines[0..lines_len]) |l| { const ls = modal.lineStartOffset(text, l); const le = modal.hxLineEndIdx(text, l); const line = text[ls..le]; if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched const at = ls + indent; // the @min can never bind: margin is 1 only when every line has a // space after its own token, which is a byte past `at + token.len` chs[chs_len] = if (commented) .{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" } else .{ .from = at, .to = at, .ins = ins }; chs_len += 1; } p.pushUndo(pane); // one edit, and the WHOLE selection rides through it (helix maps the // selection with the transaction) var cells: [MAX_SELS]SelRange = undefined; const new = p.applyChanges(text, chs[0..chs_len]) catch return; for (rs[0..got.n], 0..) |r, i| { const c = rangeCells(new, .{ .anchor = mapThroughChanges(chs[0..chs_len], r.anchor), .head = mapThroughChanges(chs[0..chs_len], r.head), }); const cc = modal.hxPos(new, c.cur); const ac = modal.hxPos(new, c.anc); cells[i] = .{ .row = @as(i32, @intCast(cc.row)) + eb.row0, .col = @intCast(cc.col), .arow = @as(i32, @intCast(ac.row)) + eb.row0, .acol = @intCast(ac.col), }; } p.setEditText(pane, new); const pl2 = p.paneCursorLines(pane) catch return; const t2 = p.flatSurface(pane, pl2) catch return; for (cells[0..got.n], 0..) |s, i| rs[i] = cellRange(t2, s.arow - pl2.row0, s.acol, s.row - pl2.row0, s.col); setPaneRanges(pane, pl2, t2, rs[0..got.n], &.{}, got.pri, expl); } /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal /// integer (helix: the selected fragment itself, no number scan around /// the cursor); a fragment that isn't an integer is a no-op. The new /// number becomes the selection, cursor on its last char. fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void { const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); const arena = p.scratch.allocator(); const frag = modal.rangeText(arena, eb.text, r.a, r.b) catch return; const rep = (modal.hxIncrement(arena, frag, delta) catch null) orelse return; p.pushUndo(pane); pane.select = false; const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return; p.setEditText(pane, new); const start = modal.lineStartOffset(new, r.a.row) + r.a.col; const cc = modal.hxPos(new, modal.prevGrapheme(new, start + rep.len)); pane.vsel = .{ .active = modal.nextGrapheme(rep, 0) < rep.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `]Space` / `[Space`: add blank lines below/above, the cursor staying /// on its text line (the selection rides along) fn addNewline(p: *Pardes, pane: *Pane, below: bool, cnt: usize) void { const eb = p.editText(pane, pane.cur_row, pane.cur_row, -1) orelse return; p.pushUndo(pane); const row: usize = @intCast(@max(0, pane.cur_row - eb.row0)); const arena = p.scratch.allocator(); const nl = arena.alloc(u8, cnt) catch return; @memset(nl, '\n'); const llen = modal.lineSlice(eb.text, row).len; const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; p.setEditText(pane, new); if (!below) { pane.cur_row += @intCast(cnt); // the line moved down; stay on it if (pane.vsel.active and pane.vsel.row >= @as(i32, @intCast(row))) pane.vsel.row += @intCast(cnt); } else if (pane.vsel.active and pane.vsel.row > @as(i32, @intCast(row))) { pane.vsel.row += @intCast(cnt); } pane.cur_pinned = true; pane.ensureCursorVisible(); } /// pair mapping for m-mode: either bracket names the pair; anything else /// surrounds with itself (quotes) fn pairFor(ch: u21) ?struct { o: u8, c: u8 } { return switch (ch) { '(', ')' => .{ .o = '(', .c = ')' }, '[', ']' => .{ .o = '[', .c = ']' }, '{', '}' => .{ .o = '{', .c = '}' }, '<', '>' => .{ .o = '<', .c = '>' }, else => if (ch <= 0x7f) .{ .o = @intCast(ch), .c = @intCast(ch) } else null, }; } /// `mi` / `ma`: select inside/around a textobject (pure range /// math over the motion surface — works on terminals too). Word and /// paragraph objects are helix textobject.rs ports; pairs/quotes are the /// plain-text scans (quotes line-scoped — ponytail). fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void { const text = p.flatSurface(pane, pl) catch return; const range = paneRange(pane, text, pl.row0); switch (obj) { 'w', 'W' => { const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); return setPaneRange(pane, pl, text, r, false); }, 'p' => { const r = modal.hxTextobjectParagraph(text, range, around, 1); return setPaneRange(pane, pl, text, r, false); }, else => {}, } const cur = modal.hxPos(text, modal.hxCursor(text, range)); const pair: ?modal.Range = switch (obj) { '\'', '"', '`' => modal.enclosingQuote(pl.lines, cur, @intCast(obj)), '(', ')' => modal.enclosingPair(pl.lines, cur, '(', ')'), '[', ']' => modal.enclosingPair(pl.lines, cur, '[', ']'), '{', '}' => modal.enclosingPair(pl.lines, cur, '{', '}'), '<', '>' => modal.enclosingPair(pl.lines, cur, '<', '>'), else => null, }; const pr = pair orelse return; var a = modal.hxOff(text, pr.a); var head = modal.nextGrapheme(text, modal.hxOff(text, pr.b)); if (!around) { // inside: shrink off the delimiters; an EMPTY pair collapses to // a 1-wide cursor on the closing char (helix) a = modal.nextGrapheme(text, a); head = modal.prevGrapheme(text, head); } setPaneRange(pane, pl, text, .{ .anchor = a, .head = head }, false); } /// `ms`: wrap the selection (or the cursor char) in a pair; the wrap /// including the pair becomes the selection fn surroundAdd(p: *Pardes, pane: *Pane, ch: u21) void { const pr = pairFor(ch) orelse return; pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col); var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return; p.setEditText(pane, new); new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; p.setEditText(pane, new); pane.msel.active = false; pane.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0; pane.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `md`: delete the enclosing pair's chars; the cursor maps through fn surroundDelete(p: *Pardes, pane: *Pane, pl: PaneLines, ch: u21) void { const pr = pairFor(ch) orelse return; // the pair is found over the motion surface, whose rows are absolute; // the edit runs in the buffer covering those rows, and every offset // below is taken in THAT text so the cursor maps through it const cur = toModalCursor(pane, pl); const r = (if (pr.o == pr.c) modal.enclosingQuote(pl.lines, cur, pr.o) else modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return; const lo: i32 = @intCast(@min(r.a.row, cur.row)); const hi: i32 = @intCast(@max(r.b.row, cur.row)); const eb = p.editText(pane, lo + pl.row0, hi + pl.row0, -1) orelse return; const text = eb.text; const drow = pl.row0 - eb.row0; // surface row -> buffer row const ra: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.a.row)) + drow), .col = r.a.col }; const rb: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.b.row)) + drow), .col = r.b.col }; p.pushUndo(pane); const a_off = modal.hxOff(text, ra); const b_off = modal.hxOff(text, rb); const cur_off = modal.hxOff(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); // the close first, so the open's position stays valid var new = modal.deleteChar(p.gpa, text, rb) catch return; p.setEditText(pane, new); new = modal.deleteChar(p.gpa, new, ra) catch return; p.setEditText(pane, new); var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; const cc = modal.hxPos(new, nc); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.vsel.active = false; pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } /// `mr`: swap the enclosing pair's chars for 's fn surroundReplace(p: *Pardes, pane: *Pane, pl: PaneLines, from: u21, to: u21) void { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; const cur = toModalCursor(pane, pl); const r = (if (fp.o == fp.c) modal.enclosingQuote(pl.lines, cur, fp.o) else modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return; const eb = p.editText(pane, @as(i32, @intCast(r.a.row)) + pl.row0, @as(i32, @intCast(r.b.row)) + pl.row0, -1) orelse return; const drow = pl.row0 - eb.row0; // surface row -> buffer row const ar: usize = @intCast(@as(i32, @intCast(r.a.row)) + drow); const br: usize = @intCast(@as(i32, @intCast(r.b.row)) + drow); p.pushUndo(pane); const out = p.gpa.dupe(u8, eb.text) catch return; out[modal.lineStartOffset(out, ar) + r.a.col] = tp.o; out[modal.lineStartOffset(out, br) + r.b.col] = tp.c; p.setEditText(pane, out); } // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- /// pull the cursor back inside `text` after a rewrite; `row0` is the /// absolute surface row of its first line (0 for a file) fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { const n = modal.lineCount(text); const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); const llen = modal.lineSlice(text, row).len; pane.cur_row = @as(i32, @intCast(row)) + row0; pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); pane.cur_pinned = true; pane.vsel.active = false; pane.msel.active = false; pane.ensureCursorVisible(); } fn pushUndo(p: *Pardes, pane: *Pane) void { // one keystroke, one undo step — even when it edited at ten cursors. if (p.multi_on and !p.multi_first) return; if (pane.file != null) return file_pane.pushUndo(p, pane); term_pane.pushUndo(p, pane); } fn doUndo(p: *Pardes, pane: *Pane) void { if (pane.file != null) return file_pane.undo(p, pane); term_pane.undo(p, pane); } fn doRedo(p: *Pardes, pane: *Pane) void { if (pane.file != null) return file_pane.redo(p, pane); term_pane.redo(p, pane); } pub const ChromeTarget = struct { col: u16, row: u16 }; /// Is this cell layout CHROME, and if so which cell should the press be /// delivered at? For the touch shells: a finger on a tag row or a resize /// handle latches a left-mouse drag, everything else is body text and gets /// one-finger scrolling and tap-as-look. A gesture is classified once, at /// finger-down, and never turns into a scroll afterwards. /// /// It lives here because it is a MIRROR of handleMouse's own hit test /// below, in both the geometry and the ORDER: the move box beats a /// horizontal handle on a tag-only pane, the rest of the tag row beats the /// fat-finger tolerance around a separator, and Tagbottom moves both the /// tag row and the h-handle together (a pane's tag on its LAST row makes /// its first an ordinary body row and puts the seam on the lower pane's /// first). It was a line-for-line clone in web.zig and gui.zig, kept in /// step by a comment in each saying it was a clone of the other; the two /// conditionals Tagbottom added went into both copies four times. /// /// The one-cell tolerance is the only thing here that is not handleMouse's /// rule: a mouse is exact, a finger is not. pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget { if (row < TOPBAR_H) 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_terms[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_terms[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; } 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; } const PointerTextSelection = struct { sel: Sel, on_tag: bool, }; /// Map one physical grid cell into the selection coordinate space shared /// by Look, Exec and the hover preview. Geometry lives here once: the tag /// is always selection row zero and body rows start at BOX_H, even when /// Tagbottom swaps their physical positions. fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16) ?PointerTextSelection { if (p.panes[id] == null) return null; const r = p.rects[id]; if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = row >= tag_y and row < tag_y + BOX_H; const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER); const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H); return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = on_tag, }; } 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; } /// Debounce by semantic grid cell rather than raw motion events. A host /// may report the same pixel position every frame; those reports must not /// postpone the preview forever. fn noteLookHover(p: *Pardes, col: u16, row: u16) 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(); _ = p.pointerTextSelection(id, col, row) 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) return; if (p.look_hover_wait) |waiting| if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial) return; p.cancelLookHover(); p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial }; } /// Re-evaluate a stationary pointer against the frame the host just /// presented. `noteLookHover` preserves an existing delay or preview only /// when this still resolves to the same canonical cell and pane lifetime; /// moving to another semantic target re-arms it, and an invisible panel /// cell cancels it. fn refreshLookHoverFromRaw(p: *Pardes) void { if (!p.pointer_inside or !p.raw_hover_intent) return; const mapped = p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row) orelse return p.cancelLookHover(); p.hover_col = mapped.col; p.hover_row = mapped.row; p.noteLookHover(mapped.col, mapped.row); } 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 = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); if (comptime pdf_enabled) if (!pointed.on_tag and pdf_pane.paneNativeReady(p, pane)) { const probe = pdf_pane.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, }; p.look_hover_wait = null; } const PointerCell = struct { col: u16, row: u16 }; fn boxContainsCell(box: panel_animation.Box, col: u16, row: u16) bool { const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; } /// Input follows the panel pixels the shaders/TTY compositor present. /// Opening panels are drawn last, so they are probed first here too. A /// cell in final geometry which has not appeared yet is deliberately not /// clickable; otherwise a user could act on invisible content. fn presentedPointer(p: *const Pardes, col: u16, row: u16) ?PointerCell { if (p.panel_presentation_pending) return null; // Closing pixels belong to a dead pane lifetime. They occlude the // canonical survivor underneath while visible, but can never dispatch // into either that survivor or a slot which reused the old pane id. var closing = p.npresented_closing_panel_tracks; while (closing > 0) { closing -= 1; const track = p.presented_closing_panel_tracks[closing]; if (!track.active()) continue; if (boxContainsCell(track.contentBox(), col, row) and boxContainsCell(track.presented(), col, row)) return null; } const phases = [_]panel_animation.Phase{ .opening, .moving }; for (phases) |phase| { // Backends paint pane slots forward within a phase. Probe them in // reverse so overlapping transition quads address the top pixel. var index = p.presented_panel_tracks.len; while (index > 0) { index -= 1; const track = p.presented_panel_tracks[index] orelse continue; if (!track.active() or track.phase != phase) continue; switch (track.effect) { .slide, .zoom => { const shown = track.presented(); if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); return .{ .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, p.screen_w -| 1))), .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, p.screen_h -| 1))), }; }, .vertical => { const clip = track.contentBox(); if (!boxContainsCell(clip, col, row)) continue; const shown = track.presented(); if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) return null; const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); return .{ .col = @intFromFloat(@floor(clip.x + u * clip.w)), .row = @intFromFloat(@floor(clip.y + v * clip.h)), }; }, .dissolve, .ascii => { if (!boxContainsCell(track.to, col, row)) continue; if (!p.panelCellChanged(col, row)) return .{ .col = col, .row = row }; const relative_col: u16 = @intFromFloat(@floor( @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, )); const relative_row: u16 = @intFromFloat(@floor( @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, )); const visible = switch (track.effect) { .dissolve => panel_animation.dissolveRevealed( track.serial, relative_col, relative_row, track.amount(), ), .ascii => switch (p.panelCellDiff(col, row)) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; return if (visible) .{ .col = col, .row = row } else null; }, // Every glyph in a motion effect's pane is in flight, // changed or not, so a cell becomes a truthful input target // only once its own glyph has settled on the canonical one. .edges, .fall, .wave, .curtain, .scramble, .typewriter => { if (!boxContainsCell(track.to, col, row)) continue; const area = panel_animation.CellArea.of(track.to); const settled = std.meta.eql( panel_animation.charSource( track, col -| area.x0, row -| area.y0, area, ), panel_animation.CharSource.settled, ); return if (settled) .{ .col = col, .row = row } else null; }, .off => {}, } } } for (p.presented_panel_tracks) |maybe| { const track = maybe orelse continue; if (!track.active() or (track.effect != .slide and track.effect != .zoom and track.effect != .vertical)) continue; if (boxContainsCell(track.to, col, row)) return null; } return .{ .col = col, .row = row }; } /// Commit the exact panel samples a backend successfully presented. An /// empty slice means that backend drew the canonical grid directly. /// Records are keyed by pane slot so hit-test order stays identical to the /// renderer even when a native ABI publishes them in paint order. pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const panel_animation.Track) void { var presented: [MAX_PANES]?panel_animation.Track = @splat(null); var presented_closing: [MAX_PANES]panel_animation.Track = undefined; var nclosing: usize = 0; var layout: [MAX_PANES]?LayoutSnapshot = if (p.submitted_panel_layout_ready) p.submitted_panel_layout else @splat(null); if (!p.submitted_panel_layout_ready) for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; }; for (&layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { const pane = p.panes[id] orelse { snapshot.* = null; continue; }; if (pane.serial != saved.serial) snapshot.* = null; }; for (tracks) |track| { if (track.phase == .closing) { const current = for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |candidate| { if (candidate.serial == track.serial and candidate.pane == track.pane and candidate.effect == track.effect) break true; } else false; if (!current or !track.active() or nclosing == presented_closing.len) continue; presented_closing[nclosing] = track; nclosing += 1; continue; } const id: usize = track.pane; if (id >= p.panes.len or !track.active()) continue; const pane = p.panes[id] orelse continue; if (pane.serial != track.serial) continue; presented[id] = track; layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; } p.presented_panel_tracks = presented; p.presented_closing_panel_tracks = presented_closing; p.npresented_closing_panel_tracks = nclosing; p.presented_panel_layout = layout; p.panel_presentation_ready = true; p.panel_presentation_pending = false; // An empty acknowledgement means canonical cells, not an invisible // transition. Retire any producer records a direct/headless fallback // deliberately did not present, then make this exact frame the future // old-grid baseline. Animated acknowledgements keep their baseline // frozen until the canonical endpoint is actually presented. if (tracks.len == 0) { p.panel_tracks = @splat(null); p.nclosing_panel_tracks = 0; p.panel_diff_pending = false; p.panel_diff_ready = false; p.rememberPresentedCells(); } // A held gesture follows the pixels just acknowledged even when the // physical pointer stayed still. Its payload already is the last // successfully mapped endpoint, so an invisible sample simply leaves // that endpoint intact for a later frame or balanced release. if (p.pointer_inside and p.drag != .none) { if (p.presentedPointer(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.refreshLookHoverFromRaw(); } fn rememberPresentedCells(p: *Pardes) void { p.panel_diff_pending = false; p.panel_diff_ready = false; const cells = p.surface.cells; if (cells.len == 0) { p.presented_cells_valid = false; p.presented_cells_layout = @splat(null); return; } if (p.presented_cells.len != cells.len) { const next = p.gpa.alloc(Cell, cells.len) catch { p.presented_cells_valid = false; p.presented_cells_layout = @splat(null); return; }; if (p.presented_cells.len > 0) p.gpa.free(p.presented_cells); p.presented_cells = next; } @memcpy(p.presented_cells, cells); p.presented_cells_cols = p.surface.cols; p.presented_cells_rows = p.surface.rows; p.presented_cells_valid = true; p.presented_cells_layout = if (p.submitted_panel_layout_ready) p.submitted_panel_layout else @splat(null); } fn panelCellChanged(p: *const Pardes, col: u16, row: u16) bool { if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return false; return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col].changed(); } fn panelCellDiff(p: *const Pardes, col: u16, row: u16) PanelCellDiff { if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return .unchanged; return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col]; } /// A backend is about to replace an unpresentable animated frame with the /// canonical grid. Stop producer tracks too, so a later successful retry /// cannot resume halfway through an animation after canonical was shown. pub fn abandonPanelAnimations(p: *Pardes) void { p.panel_tracks = @splat(null); p.nclosing_panel_tracks = 0; p.presented_panel_tracks = @splat(null); p.npresented_closing_panel_tracks = 0; p.panel_diff_pending = false; p.panel_diff_ready = false; p.panel_presentation_pending = p.panel_presentation_ready; 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 { 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; 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.presentedPointer(raw_col, raw_row) orelse { p.cancelLookHover(); // A disappearing transition cell may hide the release, but the // press it balances already has a last valid endpoint in Drag. // End only the gesture owned by this exact physical button. 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; } } // ...and any press at all takes the keyboard back off the topbar: the // mouse names where focus goes, so leaving a cursor parked on row 0 // while you click into a pane would just be a lie if (m.kind == .press) p.topbar_col = null; switch (m.button) { .none => {}, // hover tracked above; motion has no other meaning .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 (hasPdf(pane)) pdf_pane.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; // a mostly-vertical two-finger swipe's sideways drift dies // here rather than sliding the view out from under a scroll. // Charged against the gesture, not the pane, so it runs before // we ask what is hovered. if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; if (hasPdf(pane)) { pdf_pane.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 = file_pane.sourceLine(pane, pane.cur_row); const visual = file_pane.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(file_pane.rawAtDisplay(line, next)); } }, config.select_button => switch (m.kind) { .press => { // acme 2-1: a select press during an EXECUTE drag captures // a selection (heldSelection) as the execute's argument; // the execute drag keeps running to its release, which // consumes it. 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) pdf_pane.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } if (mrow < TOPBAR_H) return; // topbar: a select click is deliberately inert // the layout box (gutter cells of the tag row) wins over // the resize handles: a tag-only pane's single row IS its // pair's h-handle, and the box must stay grabbable to move 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; } } // resize handles next: a pane's own trailing edge (v: the // left column's last col; h: the seam row between a stacked // pair that is a BODY row — the upper pane's last, or with // Tagbottom, where that one is the upper pane's tag, the // lower pane's first). // The v test still wins outright, but it now also asks // whether this same cell is one of the adjoining columns' h // handles — that cell is the corner where the two lines // meet, and grabbing it drags both boundaries at once. Its // OWN column is asked first, so a row where both are split // is the gesture it always was (see Drag.border_v). 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; // the same two rows renderPane painted through (see there): // the tag's, and the body's first. A Sel row is still 0 for // the tag and BOX_H upward for the body whichever end they // are at, so the mapping happens HERE and everything // downstream of a Sel is untouched. const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; 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) { // left press on the tag row: focus the tag AT that // column (a rendered-tag column — the mouse and the tag // agree, no clamp into the tail) and anchor a sweep, so // dragging selects any span of it, path included p.active = id; p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); pane.tag_anchor = pane.tag_col; p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; exitTagEdit(pane); // clicking the body leaves tag editing const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; // Ctrl rides on the drag rather than firing here: the // click does not place the modal cursor until RELEASE // (dragRelease), and a goto asked at press time would // answer about wherever the cursor happened to be. p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl, .pdf = pdf_pane.pointerStart( p, pane, config.select_button, mcol, mrow, false, ), } }; } }, .drag => p.dragUpdate(mcol, mrow), .release => 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) pdf_pane.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } if (mrow < TOPBAR_H) { // global tagbar: EXECUTE runs the command under the // click (a stray select click must never Kill) if (m.button == config.exec_button) { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); const word = wordAtCol(bar, file_pane.rawAtDisplay(bar, mcol)); // a topbar word runs on the PRESS — there is no // drag to chord into, so the argument is simply // whatever is selected right now: select a word, // ...but only for a builtin that HAS somewhere to // put one (see takesArg): a word left selected in // some pane an hour ago is not an argument to // Kill, and splicing it made a name that matches // no builtin and therefore went to a shell. 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); // not a builtin: an ordinary acme 2-1 if (word.len > 0) _ = p.execute(p.active, p.withArg(word, held)); } 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 = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H; if (mcol < r.x + config.GUTTER and !on_tag) { // gutter: right scrolls DOWN to here, mirroring left's up; // middle matches left. Right focuses (look lands you // there); middle does not. 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 (p.pointerTextSelection(id, mcol, mrow)) |pointed| { if (m.button == config.look_button) p.active = id; // Click and delayed hover enter through the same physical // cell mapper. From here on both also share expandedSel; // hover merely stores the result outside Pane.sel. 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 = pdf_pane.pointerStart( p, pane, m.button, mcol, mrow, pointed.on_tag, ), }, }; } }, .drag => p.dragUpdate(mcol, mrow), .release => 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) 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; // a corner also drives its column's pane pair, off the SAME // mouse position but through its own clamp — the geometry a // clamp reads (widths for x, heights for y) is frozen for the // whole drag and never crosses axes, so one edge parked at its // stop leaves the other tracking the mouse if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) { const a = p.rects[p.col_terms[k.col][k.idx]]; const b = p.rects[p.col_terms[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_terms[cc][k]]; const b = p.rects[p.col_terms[cc][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else d.cur_y = mrow; }, .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); pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); // A Tagbottom drag STAYS IN THE REGION ITS ANCHOR STARTED // IN, which is the one place the two coordinate spaces // genuinely disagree: Sel row 0 is the tag and the body // counts up from BOX_H, but on screen the tag is now BELOW // the body. Sweeping down past the last line onto the // tagline — the ordinary "select to the end" gesture — would // therefore drive r1 to the TOP of Sel space and invert the // selection: the highlight jumps above the anchor, and a 1-2 // Cut on it deletes lines nobody swept. So a body-anchored // drag is clamped to the body rows and a tag-anchored one // (execute/look only; the left button's tag sweep is its own // .tag drag) stays on the tag, which is one line anyway. // The cost is that a body sweep can no longer be extended // onto the bottom tagline to pick up the tag text. if (p.settings.tag_bottom) { 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, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H); } else pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); if (comptime pdf_enabled) pdf_pane.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } }, .tag => |d| { // the tag cursor follows the mouse across the rendered tag; the // row is ignored (a tag is one line) and the anchor stays where // the press put it, so this is the mouse's `v` const pane = p.panes[d.id] orelse return; const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(@max(0, c))))); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, } } /// THE ACME INVERSION: while a script holds this pane's `event` file open, /// buttons 2 and 3 in it belong to the script. The words in its tag are /// ITS commands — `Step`, `Run`, `Clear` — and pardes has never heard of /// them, so it reports the click and performs nothing. Returns true when /// the caller must not run the builtin. /// /// Keyboard Enter/Tab are deliberately NOT suppressed, unlike acme (which /// has no keyboard equivalent to suppress): a scripted pane stays /// editable, and a script that dies mid-run cannot leave you unable to /// execute anything in it. fn reportGesture( p: *Pardes, id: usize, cmd: Builtin, text: []const u8, on_tag: bool, operand: PointerOperand, chorded: bool, ) bool { if (!p.fs.scripted(id)) return false; const is_look = cmd == config.look_cmd; const action: acmefs.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); // acme(4)'s two flag vocabularies at the same bit positions. For an // exec, bit 1 is "this is a builtin"; for a look, it is "pardes can // act on this without loading a file", which is the same fact plus a // word that is not a path. Bit 2 is acme's "the text indicated is a // null string that has a non-null expansion", which is exactly what an // empty range plus text means here. var flag: u32 = if (named) acmefs.flag_builtin else 0; if (range.q0 == range.q1 and text.len > 0) flag |= acmefs.flag_expansion; if (is_look) { if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= acmefs.flag_filename; } else if (chorded) flag |= acmefs.flag_chorded; return acmefs.noteAction(p, id, action, range.q0, range.q1, flag, text); } /// THE BYTE RANGE A GESTURE NAMES, in the coordinates `addr` and `data` /// speak — and it must name the TEXT REPORTED WITH IT, because a script /// that does not recognise a record writes it back and pardes then /// re-derives the text from these two numbers. A range that started at the /// pointer instead of at the operand would execute `l D` for a click on /// the `l` of `Del`. /// /// So the start comes from whatever the operand actually resolved: an /// expanded word's own column, or the leading corner of the selection it /// reused. When neither is available — a modal `v`/`x` selection, a /// terminal's projected screen, a PDF — the answer is acme's null range at /// the click, which its flag bit 2 already has a meaning for: the text /// travels, the range does not claim to be it. fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) acmefs.PaneFs.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 = file_pane.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 .{}; 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 = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - config.PREFIX_W); 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.hxOff(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 { // Nothing expanded — a click on `#`, `*`, `|`, a blank cell. A // WATCHED pane still owns it: the script decides what a character // pardes has no word for means, and life.py's grid is made of // exactly those characters. 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 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; if (c + 1 < p.ncol and d.cur_x != old_seam) { const combined: u64 = p.col_w[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 var nl: u64 = (d.cur_x + 1) -| p.col_x[c]; nl = std.math.clamp(nl, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW)); const pair = p.col_weight[c] + p.col_weight[c + 1]; if (pair > 1 and combined > 0) { const numerator = @as(u128, pair) * nl + combined / 2; const left = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1); p.col_weight[c] = left; p.col_weight[c + 1] = pair - left; } } // and the corner's other half. The two commits are independent // — column weights are widths, pane vweights are heights, and // neither reads the other — so the order here does not matter // and a failed one cannot lose the other. if (d.corner) |k| { if (d.cur_y != seamRowOf(p, k.col, k.idx)) p.applyRowSplit(k.col, k.idx, d.cur_y); } }, .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx)) p.applyRowSplit(d.col, d.top_idx, d.cur_y), .move => |d| { p.moveTerm(d.id, d.cur_x, d.cur_y); // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { if (p.layoutFindTerm(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 = pdf_pane.pointerRelease( p, pane, s.pdf, s.button, s.chorded, ); defer release.deinit(p.pdf_gpa); // 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) { // keep a dragged selection highlighted; a plain click clears // it. Either way pin the cursor; files also enter normal // mode, terminals keep the mode they had (tty keeps the // mouse usable, insert keeps typing where you clicked). 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; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { // the row the user clicked, as the LAST FRAME drew it: // which line it showed and the byte column it started // at, so a click lands on the character under the // pointer whether or not that row is a continuation const w = pane.wrapAt(body_vis); pane.cur_row = w.line; pane.cur_col = p.paneByteAtDisplay( pane, w.line, w.at, sl.c1 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0), ); pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; pane.pending = 0; // A fresh left gesture replaces every modal selection. // Keeping an explicit v/x anchor made a plain click // extend it, leaving no mouse-only way to dismiss it. pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is // Ctrl-click IS `gd`, asked now that the cursor has // landed — the mouse spelling of the keyboard motion, // through the identical request. A ctrl-DRAG still // selects and still asks, about where it started, // which is the same thing `gd` would answer. if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { // acme execute (middle) / look (right): a no-drag click // expands to the word under it first; a captured chord // argument rides along and is consumed here. // WHERE THE CLICK LANDED, before any expansion — as a body // position, the way the left button converts its drag end. const clk = pane.sel[b]; const operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) pane.sel[b].state = .none; // A look that names no file SEARCHES from where you are, so // the click has to say where that is and the search walks // forward from there. Not PINNED — a shell's cursor still // belongs to the shell, and the search reads this in the // same event. 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; } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; p.dispatchPointerBuiltin(s.id, cmd, txt, .{ .on_tag = clk.r0 < BOX_H, .operand = operand, }); } }, .tag => {}, // dragUpdate already left the tag cursor + selection set .none => {}, } } /// Commit a horizontal border to the pane weights: in column `cc`, the pane /// at index `k` and the one under it split their combined height at row /// `cur_y`. Shared by the plain h-drag and the h half of a corner drag — /// the same gesture landing on the same boundary must settle identically /// whether or not a column edge came along for the ride. fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { if (k + 1 >= p.col_n[cc]) return; const a = p.panes[p.col_terms[cc][k]] orelse return; const b = p.panes[p.col_terms[cc][k + 1]] orelse return; const ra = p.rects[p.col_terms[cc][k]]; const rb = p.rects[p.col_terms[cc][k + 1]]; const combined: f32 = @floatFromInt(ra.h + rb.h); // the handle is the upper pane's LAST row (inclusive, hence the +1), or // with Tagbottom the lower pane's FIRST — which is that row's height // already, with nothing to add. Either way a no-drag click hands back // exactly ra.h and nothing moves. var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y); nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); const pair = a.vweight + b.vweight; a.vweight = pair * (nt / combined); b.vweight = pair - a.vweight; } /// acme 1-2 (Cut) / 1-3 (Paste): middle or right tapped while the left /// button holds a selection drag. The first tap converts the mouse /// selection into the modal one (vsel anchored at the drag start, cursor /// at its end; a plain click is a bare cursor). Cut yanks + deletes the /// selection; Paste replaces it with the yank register, or splices the /// register in literally at a bare cursor — so 1-2 then 1-3 in one hold /// nets out to Snarf (copy: the text returns, the register keeps it). /// In a tty-mode shell 1-3 instead pastes into the pty (forwarded click /// + bracketed paste) and 1-2 is inert. 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) p.setYank(pv.selection_text); return; }; if (pane.mode == .tty) { // tty: nothing can be cut — 1-2 stays inert; the pty owns the // screen. 1-3 pastes like a terminal emulator: the click is // forwarded first (only when the app listens for mouse) so // mouse-aware programs put their cursor under it, then the // register is typed — bracketed when the app set mode 2004 // (readline/vim/helix strip the markers), else with \n turned // to \r like any unbracketed paste. 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 (term_pane.reportsMouse(pane)) { // dragUpdate keeps sel[sel_slot] tracking the held select button, so // the click lands where the mouse is at this tap; 1-based, // body-relative (the tag row is ours, not the app's) 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 (term_pane.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 }); } } p.typeToTty(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) config.PREFIX_W 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 = p.paneByteAtDisplay(pane, w0.line, w0.at, sl.c0 - pfx); const col1 = p.paneByteAtDisplay(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.pending = 0; 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) p.normalDelete(pane, true); } else if (pane.vsel.active) { p.normalReplaceYank(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)) }; p.pushUndo(pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; file_pane.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 p.normalPaste(pane, true); // terminal: splice run text at the cursor } // ---- layout surgery ---- pub fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k }; } } return null; } fn layoutInsert(p: *Pardes, c: usize, idx: usize, id: usize) void { var k = p.col_n[c]; while (k > idx) : (k -= 1) p.col_terms[c][k] = p.col_terms[c][k - 1]; p.col_terms[c][idx] = id; p.col_n[c] += 1; } /// Remove from the layout. An emptied column hands its width to a neighbor /// (else every surviving column reflows sideways) before shifting down. pub fn layoutRemove(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; const c = f.col; var k = f.idx; while (k + 1 < p.col_n[c]) : (k += 1) p.col_terms[c][k] = p.col_terms[c][k + 1]; p.col_n[c] -= 1; if (p.col_n[c] == 0) { if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; var j = c; while (j + 1 < p.ncol) : (j += 1) { p.col_terms[j] = p.col_terms[j + 1]; p.col_n[j] = p.col_n[j + 1]; p.col_weight[j] = p.col_weight[j + 1]; } p.ncol -= 1; } } /// Newtty: 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 { const src = p.panes[from] orelse return; const free = p.freeSlot() orelse return; const nt = p.newShell(free, paneDir(src)) catch return; nt.greet = true; nt.mode = .tty; const parent = p.splitParent(from); const f = p.layoutFindTerm(parent).?; p.layoutInsert(f.col, f.idx + 1, free); p.splitBelow(parent, nt); p.active = free; } /// Joincol: fold the active pane's column into the one on its right, /// carrying its panes and width across. Inert without a right neighbour. pub fn joinCol(p: *Pardes) void { const f = p.layoutFindTerm(p.active) orelse return; const c = f.col; if (c + 1 >= p.ncol) return; const dst = c + 1; p.col_weight[dst] +|= p.col_weight[c]; for (0..p.col_n[c]) |k| p.col_terms[dst][p.col_n[dst] + k] = p.col_terms[c][k]; p.col_n[dst] += p.col_n[c]; var j = c; while (j + 1 < p.ncol) : (j += 1) { p.col_terms[j] = p.col_terms[j + 1]; p.col_n[j] = p.col_n[j + 1]; p.col_weight[j] = p.col_weight[j + 1]; } p.ncol -= 1; } /// Whether `source_id` can donate half its width to a new column. This is /// public so callers that must create a pane first can reject before that /// creation emits any native-side work. pub fn layoutCanSplitColumn(p: *Pardes, source_id: usize) bool { if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false; const source = p.layoutFindTerm(source_id) orelse return false; // Refresh derived widths: public layout surgery may be chained between // syncs, and a cached width must never admit a now-too-narrow split. p.computeGeom(); if (p.col_w[source.col] < config.MINW * 2) return false; const weight = p.col_weight[source.col]; if (weight >= 2 and weight % 2 == 0) return true; for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2) return false; return weight > 0; } /// Put `id` in a fresh column immediately beside `source_id`, taking the /// new column's width only from the source column. `before` is used by a /// first document, which belongs to the left of the shell that opened it; /// Newcol and Alt-c put the new column on the right. /// /// `id == source_id` moves one pane out of a stack. Its vertical weight is /// absorbed before removal, while the column's horizontal weight remains /// in place to be split. In either form the total column weight is exactly /// unchanged, so every unrelated column keeps both its width and its x. pub fn layoutSplitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { if (id >= MAX_PANES or p.panes[id] == null) return false; if (!p.layoutCanSplitColumn(source_id)) return false; const source = p.layoutFindTerm(source_id) orelse return false; const source_col = source.col; var old_weight = p.col_weight[source_col]; const needs_rebase = old_weight < 2 or old_weight % 2 != 0; if (needs_rebase) old_weight *= 2; if (id == source_id) { if (p.col_n[source_col] <= 1) return false; p.absorbVWeight(id); p.layoutRemove(id); } else if (p.layoutFindTerm(id) != null) return false; // A pathological hand-written dump can restore a positive proportion // below one fixed-point quantum. Rebase every column together before // splitting; ratios and therefore geometry remain identical. if (needs_rebase) { for (0..p.ncol) |column| p.col_weight[column] *= 2; } const source_weight = old_weight / 2; const new_weight = old_weight - source_weight; p.col_weight[source_col] = source_weight; const c = source_col + @intFromBool(!before); var j = p.ncol; while (j > c) : (j -= 1) { p.col_terms[j] = p.col_terms[j - 1]; p.col_n[j] = p.col_n[j - 1]; p.col_weight[j] = p.col_weight[j - 1]; } p.col_weight[c] = new_weight; p.col_terms[c][0] = id; p.col_n[c] = 1; p.ncol += 1; return true; } /// Snap every pane in column `c` to its on-screen row count so later /// weight edits move ONLY the panes they name: computeGeom rounds /// round(avail*w/vsum) per pane, and with fractional weights a split or /// absorb elsewhere in the column can jiggle a bystander by a row. fn snapColWeights(p: *Pardes, c: usize) void { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); } } /// Hand a dying pane's rows to ONE sibling (the pane above, or below for /// the topmost) so the rest of the column keeps its sizes bit-identical — /// the deletion mirror of splitBelow. Call while `id` is still in the /// layout, with rects current. pub fn absorbVWeight(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; p.snapColWeights(f.col); // the pane above, but never a result list: walking past a stack of them // lands on the pane that spawned it, so those keep their heights and // only the spawner grows. The topmost pane has only what is below it. var sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; var k = f.idx; while (k > 0) : (k -= 1) { sib = p.col_terms[f.col][k - 1]; if (p.panes[sib]) |pp| if (if (pp.file) |ff| output_pane.fileTraits(ff.output).doc else true) break; } if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } /// The parent a new pane splits from must be tall enough that splitBelow /// leaves the NEW pane at least 2 body rows (the parent keeps tag+1 row, /// the new pane needs tag+2). A too-short choice is swapped for a /// qualifying pane (same column first, keeping the split local), else the /// tallest pane anywhere. fn splitParent(p: *Pardes, want: usize) usize { const need = 2 * BOX_H + 3; if (p.rects[want].h >= need) return want; if (p.layoutFindTerm(want)) |f| for (0..p.col_n[f.col]) |k| { if (p.rects[p.col_terms[f.col][k]].h >= need) return p.col_terms[f.col][k]; }; var tallest = want; for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; if (p.rects[pid].h >= need) return pid; if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; }; return tallest; } /// Open a window BELOW `src` (acme-style) without rebalancing the column: /// shrink ONLY src to its content height (cursor row kept visible) and hand /// the freed rows to `nw` — together they fill src's old slot and the other /// panes keep their sizes bit-identical (weights snap to row counts). fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { const src = p.panes[src_id] orelse return; const src_h = p.rects[src_id].h; const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; const cur: u16 = if (!src.isTerminal()) body / 2 else term_pane.gridCursor(src).y + 1; // cap keep so a content-full source still leaves the new pane a tag + // a few body rows (an Alt-n from a full shell was born 0 rows tall) const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); if (p.layoutFindTerm(src_id)) |f| for (0..p.col_n[f.col]) |k| { const pid = p.col_terms[f.col][k]; if (p.panes[pid]) |pp| if (pp != nw) { pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); }; }; src.vweight = @floatFromInt(BOX_H + keep); nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); // a non-doc buffer is worth exactly its own text: a three-hit +Search // is four rows, not half the source. Nothing else can want the rows, // so they go straight back to the pane they were taken from. if (nw.file) |f| if (!output_pane.fileTraits(f.output).doc) { // trimmed: every row ends in a newline, and the empty line after // the last one is not a result const want: f32 = @floatFromInt(BOX_H + file_pane.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); if (want < nw.vweight) { src.vweight += nw.vweight - want; nw.vweight = want; } }; } /// 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_terms[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 term_pane.gridCursor(tt).y != 0 or term_pane.scrollbar(tt).total > tt.rows) return; p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows p.absorbVWeight(tty_id); p.layoutRemove(tty_id); p.deinitPane(tt); p.panes[tty_id] = null; if (p.active == tty_id) p.active = keep_id; } /// a terminal already in `dir` and still at its prompt, else a fresh shell /// there at the bottom of the rightmost column. Backs middle-click send /// from a file pane. Does NOT focus (execute keeps you where you were; look /// focuses). /// /// A terminal whose tty is TAKEN (vim, a pager, an agent) is not a match for /// its own cwd: it cannot run a command line, so the scan keeps going and /// spawns rather than pretending it found somewhere to type. The directory /// is compared FIRST because that comparison is free and the occupancy /// question costs a walk through /proc — a window full of shells in other /// directories is not worth one syscall. 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 = p.splitParent(p.col_terms[rc][p.col_n[rc] - 1]); const f = p.layoutFindTerm(src).?; p.splitBelow(src, nt); p.layoutInsert(f.col, f.idx + 1, free); } else p.layoutInsert(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 pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content }; pane.cur_pinned = true; return pane; } /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): /// swap the tty_only boot pane for a file pane holding `content` verbatim /// — file_pane.open minus the disk read (cases carry their buffer inline). /// Unreachable from any shell; keep it dumb. pub fn hxOpenFileContent(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, "/hxcase.txt"); errdefer p.gpa.free(path); p.deinitPane(p.panes[0].?); p.panes[0] = null; const pane = try p.newDocPane(0); pane.file = .{ .path = path, .content = copy }; 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; var state = paneNormalState(pane); // The way OUT of a PDF, because plain Escape below is the document's // own cancel (selection, search overlay) and never moves focus. Before // the parser: `config.escape` matches a shifted Escape too, since shift // is consulted only where a binding asks for it. if (hit(key, config.leave_pane)) { state.clear(); putPaneNormalState(pane, state); pane.select = false; return p.runBuiltin(.Last, p.active, "", null); } // A PDF has no body character to find, so its bare `f` is the direct // document-outline door. Prefix continuations still go through the // shared parser, and text/terminal panes retain `f` unchanged. if (state.prefix == .none and isPrefix(key, 'f')) { state.clear(); putPaneNormalState(pane, state); return p.runBuiltin(.PdfSections, p.active, "", null); } const parsed = normal_input.parse(&state, normalInput(key)); putPaneNormalState(pane, state); switch (parsed) { .pending, .ignored, .unbound => {}, .action => |semantic| { const result = pdf_pane.applyNormal( &pane.pdf.?, p.pdf_gpa, semantic, p.native_images, .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h }, pdf_pane.paneViewport(p, pane), pdf_pane.paneGeometry(p, pane), ); if (result.page_changed) pdf_pane.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.startSearch(pane, config.search_marker), .search_forward => p.lookWalk(1), .search_backward => p.lookWalk(-1), } }, } } /// stack a fresh doc pane at the top of the LEFT column (acme convention: /// files left, terminals right), halving ONLY the old top pane's slot so /// the rest of the column keeps its sizes; then evict a leftover pristine /// shell. The placement of last resort — a first doc normally takes a /// column of its own (placeDoc), and this only runs when the column bar /// is full. fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { const lc = 0; if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| { p.snapColWeights(lc); const h = p.rects[p.col_terms[lc][0]].h; nt.vweight = @floatFromInt(@max(1, h / 2)); top.vweight = @floatFromInt(@max(1, h -| h / 2)); }; p.layoutInsert(lc, 0, free); p.active = free; p.evictLonePristineTty(lc, free); } // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- /// Focus pane `id` and, for a nonzero 1-based `at.line`, put its modal /// cursor there (`at.col` likewise, 0 = line start): files recenter the /// view on it, terminals ride their scrollback to it. Both look targets /// that name a live pane land here — a path a pane already holds, and /// `@pN:LINE:COL`. A RANGED spot selects (selectSpan below). pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; if (hasPdf(pane)) { if (comptime pdf_enabled) { const pv = &pane.pdf.?; if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane); } return; } if (at.line == 0) return; if (pane.file) |*f| { if (at.line > file_pane.nlines(p.gpa, f)) return; const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } // land the modal cursor on the target line (and keep // ensureCursorVisible agreeing with the recenter — a stale cursor // would yank the view right back) 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(); } /// Select the span a RANGED look word names (config.range_sep): the two /// ends are block-cursor CELLS, so this is the same cellRange/setPaneRange /// pair every motion writes back through — the cursor lands on the span's /// last cell with the anchor on its first, which is where helix leaves you /// after a search too. /// /// EXPLICIT, so the acme chords act on it like a v/x selection: the whole /// point of `n` selecting a hit is being able to chord the match straight /// into the next command. /// /// Everything clamps, because a range is a claim about a file that may /// have changed underneath it: hxOff pins a row past the end to the last /// line and a column past the end to that line's terminator, so a stale /// row selects what is still there instead of crashing or highlighting /// garbage. A whole-lines range (no end column) runs to the terminator by /// asking for a column no line can have, which is helix's own `x`. fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) 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 = cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); setPaneRange(pane, pl, text, r, true); } /// focus the pane already loaded on `path` (exact match), if any: file /// panes recenter on a :NN line like the look dedup always has, image /// panes just focus. Returns false when no pane holds that path. fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { p.active = i; return true; }; if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { p.focusPaneLine(i, at); return true; }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; p.focusPaneLine(i, at); return true; } return false; } /// LOOK — the Look builtin's body, and so what a right click, an Enter and /// the word `Look` all end at. Resolve `txt` against the panes' directories /// and open (or focus) whatever it names; a word that names nothing is a /// search of the pane it came from, which is acme's button-3. pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; p.noteHaptic(.look); // ...and this pane is now the head of the n/N walk. Recorded HERE, at // the one dispatcher every look reaches, so a right click, an Enter, a // stepped result row and the word `Look` all count alike. p.noteLookSource(id); const trimmed = std.mem.trim(u8, txt, " \t\r\n"); // `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the // pane that answers — looking at a thing means being SHOWN it, and a // command's output is what there is to be shown. Looking at a // DIRECTORY has always been exactly this (below: focus a shell there // and make it `ls`); this is that rule spelled generally. if (config.commandWord(trimmed)) |cmd| { if (p.execute(id, cmd)) |dst| p.active = dst; return; } var realbuf: [4096]u8 = undefined; // an already-loaded pane wins BEFORE any filesystem resolve: the // web build has no fs (a look would otherwise be inert even for // panes sitting in the session), and native gets the same dedup // it always did, just without touching disk. Pane paths are // canonical (realpath'd or dump-given), so match the word as-is // here and joined onto each directory below. const pl = look.parsePathLine(trimmed); if (comptime pdf_enabled) if (pdf_pane.lookSection(p, id, pl.path, pl.at)) return; if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return; // The word is resolved against the pane DIRECTORIES in access // order: the pane the click came from FIRST — its answer is the // one taken, so nothing that resolves today moves — then every // other live pane, most recently focused first (the jump stack runs // least-recent -> active, so it is that array backwards; the // clicked pane is not always `active`, a right click does not // focus). Only when ALL of them fail does the word fall through to // the search below: a name you can read in one window is openable // from any of them. Each attempt is a realpath + an open and // shells share cwds constantly, so a seen-list holds every // directory to one try; an absolute word — and `@pN`, which reads // no directory at all — answers the same everywhere and stops // after the first pass. 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.focusPaneByPath(j, pl.at)) return; } found = look.resolve(txt, dir, &realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } switch (found) { // acme button-3: a word that names no file/dir is a search of // the pane it was clicked in — exactly what `/` runs, and then a // step onto a hit, so a click GOES somewhere and clicking again // goes to the next one. Paths (src/a/b.rs:100) still resolve above // and open; only the non-file case falls through here. A shell // searches its scrollback like anything else, EXCEPT in tty // mode, where the click belongs to the program on the other // end; an image pane has no text to search either way. .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (pane.image != null or bmode == .tty) return; // ...and then STEP it, which is the other half of button-3: a // click does not merely LIST the hits, it goes to one — the // one AFTER the word clicked, since runSearch armed the walk // where the click put the cursor. Asking again is free: the // same pattern refills its own list rather than opening a // second, so clicking a word repeatedly walks its hits. p.runSearch(id, trimmed, .text, .cursor) catch |err| { p.reportError(id, "search", err); return; }; const at = pane.search_row; _ = p.searchStep(id, 1); // past the last hit, back to the first: acme's search is a // RING, and a step that could not move left the row where it // was (searchStep clamps rather than wrapping, because n/N are // also how `]d`/`[d` walk to the end of a list and stop). if (at != null and pane.search_row == at) { pane.search_row = null; _ = p.searchStep(id, 1); } }, // `@p7:10:5`: pane 7, line 10, column 5 — how a search result // points at a terminal or an output buffer, neither of which // has a path. .pane => |t| p.focusPaneLine(t.id, t.at), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // focus an existing terminal on this dir, else fork one below. // A terminal whose tty is taken is not that terminal: `ls\r` // typed into vim is `ls\r` typed into vim. for (p.panes, 0..) |slot, i| { if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { p.active = i; p.emitWrite(i, "ls\r"); return; }; } const free = p.freeSlot() orelse return; const nt = p.newShell(free, dir) catch return; nt.greet = true; const src = p.splitParent(id); const f = p.layoutFindTerm(src).?; p.layoutInsert(f.col, f.idx + 1, free); p.splitBelow(src, nt); p.active = free; }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return; const nt = pdf_pane.openPane(p, free, target.path, target.at.line) catch return; 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; const nt = file_pane.open(p, free, target.path, target.at.line) catch return; if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); p.placeDoc(id, free, nt); // center the target line: the pane's real body height only // exists after placement, so lay out now and pull the // scroll up by half a body (line 0 opens stay at the top) p.computeGeom(); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; // ...and only THEN select a range, restoring that scroll: // setPaneRange keeps its cursor visible, and a pane this fresh // has no true geometry yet for it to judge against (pane.rows // is only refreshed in sync), so the centering just computed is // the answer and ensureCursorVisible's is not. 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; const nt = image_pane.create(p, free, target.path, &.{}) catch return; p.placeDoc(id, free, nt); }, } } /// how deep `execute` may re-enter itself. Nothing can reach this today: /// every door back in strips at least one word (`Exec X` -> `X`) or one /// pair of delimiters (`` @`X` `` -> `X`), so the command line strictly /// shrinks and a cycle cannot close — executing the bare word `Exec` runs /// out of argument immediately. The counter is here so that a syntax added /// later which does NOT shrink (an alias, a macro) stops instead of hanging /// the editor, and the ceiling is small because a human nesting eight deep /// has made a different mistake. const max_exec_depth = 8; /// EXECUTE — the Exec builtin's body, and so what a middle click, a Tab /// and the word `Exec` all end at. A builtin's NAME runs the builtin; /// anything else is a command line typed at a shell. Returns the pane it /// was typed into, which is what Look focuses and an execute deliberately /// does not. 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; // Before the builtin dispatch, and only at depth zero: `Exec ls` comes // back through here as `ls` (executeBuiltinLine holds the depth), and // one Tab is one thing the hand did, however many words it unwraps to. 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; // Anything not in the builtin vocabulary is a command line typed at // a shell. Startup config calls executeBuiltinLine directly and never // reaches this fallback. // A terminal runs in itself — as long as its tty is still the prompt we // forked. Every document kind — real/output file, image, or PDF — and a // terminal currently held by a full-screen program run in a terminal for // their directory (an existing prompt when possible, otherwise a freshly // forked shell). In particular, never emit a PTY write addressed to an // image's no-PTY pane slot, and never type a command line at vim. const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null; term_pane.padOutputBelowEdits(p, dst); if (term_pane.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; } /// Parse and dispatch exactly one builtin command. This is the canonical /// builtin path used both by ordinary Exec and by startup configuration; /// unlike execute(), it deliberately has no external-shell fallback. /// False means blank, malformed, unknown, or recursion-limited. 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; // The builtins that take an ARGUMENT match their name with a TAIL: // `Restore `, `Find `, `Grep `, `Rename `, // `WsSymbols `, `Theme `, `Font ` (gui only), and the // two verbs themselves — `Look `, `Exec `, which is what // makes `` @`Look .` `` nest (the tail goes straight back through // here). Every other name must match WHOLE, so `Kill foo` is a shell // command and not Kill. 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 { var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } /// Run a builtin on pane `id`. `txt` is the executed text (Restore reads /// its path back out of it) and `arg` the builtin's ARGUMENT — the tail /// after the name, which is why Grep and Find run straight away when there /// is one instead of asking, and which for Look and Exec is the whole /// operand. A gesture that points at a word (a click, an Enter) passes it /// as `arg` with no `txt`: it named no builtin, config.look_cmd did. The /// leader passes "" and null: a key path names a builtin, never an /// argument. The topbar builtins are global; the pane-scoped ones (Save, /// Del, Delcol, the image toggles, and the window group, which moves focus /// relative to `id`) act on `id`. The null-pane check is the one guard /// every builtin used to share, so it stays here rather than in each. 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; const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; builtins.registry.dispatch(b, c); } /// Generated setting builtins converge here. The descriptor is compile- /// time data but the state is ordinary owned data; no vtable or callback /// layer sits between the command enum and these fields. pub fn applySettingBuiltin(p: *Pardes, setting: runtime_cfg.Setting, arg: ?[]const u8) void { const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; 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)) { p.setThemeIndex(i); return; }; }, .font => { if (comptime !font_picker) return; const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); const matches = fonts.list(p.scratch.allocator(), want); if (matches.len == 0) return; if (!runtime_cfg.requestFont(&p.settings, matches[0].path, matches[0].name)) return; p.font_request_taken = false; }, else => _ = runtime_cfg.applySimple(&p.settings, setting, arg), } 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 (transition_changed or tagline_metrics_changed) { // The backend may still show the last acknowledged sample. Keep // input inert until it has replaced that sample with canonical // pixels instead of merely forgetting the producer-side tracks. // A tagline-size change also invalidates the raster metrics used // by a frozen old frame; snapping is the only honest old/new pair. p.abandonPanelAnimations(); } } /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a /// document: it is the result list belonging to the pane that asked for it, /// so it never claims a column and is never anyone else's split parent — it /// lands right below `from_id`, be that a shell, a file or another list, /// and `from_id` alone pays the rows (several lists just stack there). /// A real doc joins the docs: any doc already open is the split parent (the /// source if it IS one, else the one most recently worked in) and the /// newcomer lands right below it, so docs share a column. The FIRST doc of /// the session instead gets a column of its own on the left (acme: files /// left, shells right). Only the calling/source column donates half its /// width; every other column keeps its boundary. A full column bar, or a /// result list whose source died, stacks into the leftmost. /// A new file opens a column only when the split leaves both sides wide /// enough to read; otherwise it stacks as a pane. Halving is exact, so the /// narrower side is floor(width/2) >= min_cells iff width >= 2*min_cells. fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { const f = p.layoutFindTerm(source_id) orelse return false; p.computeGeom(); return p.col_w[f.col] >= min_cells * 2; } pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| output_pane.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| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) { src_id = from_id; }; // Opened from somewhere that is NOT a doc (a shell, a results list): // the file joins the column it was last being READ in, which is the // newest doc on the jump stack. The stack already IS that record — it // is where the keyboard has been — so nothing new is remembered here; // asking it is the whole change. What this replaces was the first doc // in slot order, i.e. pane-id ALLOCATION order, so which column your // file landed in depended on how the session had handed out ids rather // than on where you were working. // // The answer wanted is the COLUMN, and it stays right once the pane // itself is closed: trackJump compacts dead entries out, so the walk // falls through to the next doc remembered in that same column. The // serial test is the one trackJump uses — slots are reused, so an entry // whose pane has been replaced names a pane that is gone, not the // newcomer sitting in its slot (`free`, this very pane, among them). 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| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp)) { src_id = j.pane; break; } } } // A doc no jump remembers is still a doc: a restored session focuses // one pane, not each, and the stack is finite. The rule that a second // doc never claims a second column outranks knowing where you were, so // the old slot-order scan stays as the fallback. if (doc and src_id == null) for (p.panes, 0..) |sl, i| { if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp))) { src_id = i; break; }; }; // A result list belongs to its spawner: it lands directly BELOW it — // on top of the lists already there — and its rows come out of the // SPAWNER, never a bystander, so opening another list (or deleting // one, see absorbVWeight) leaves every other pane's height untouched. if (!doc) if (p.layoutFindTerm(from_id)) |sf| { p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; if (src_id) |sid| { const src = p.splitParent(sid); const sf = p.layoutFindTerm(src).?; p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(src, nt); p.active = free; return; } if (doc and p.ncol < MAX_COLS and p.columnFitsHalves(from_id, 100)) { if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt); p.active = free; return; } p.stackDocLeft(free, nt); } // ---- 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 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 p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); // BY POINTER: `origin_arg` is a slice into the File's own inline // buffer, and a by-value capture would leave it pointing at a // stack copy that dies before the ZON is written. var dp: dump.Pane = if (pane.file) |*f| try file_pane.dumpPane( arena, pane, f, tag, body, scroll, if (f.output) |o| output_pane.word(o.from) else "", if (f.output) |*o| o.arg() else "", ) else if (hasPdf(pane)) blk: { if (comptime !pdf_enabled) unreachable; const pv = &pane.pdf.?; // Keep the dump schema backwards-compatible: a raster-backed // document rides the existing binary image record, while its // `scroll` field is the zero-based PDF page. Restore inspects // the extension and reconstructs the semantic PDF pane. break :blk try image_pane.dumpPane(arena, pane, tag, body, pv.page, pv.path, &.{}); } else if (pane.image) |iv| try image_pane.dumpPane(arena, pane, tag, body, scroll, iv.path, iv.raw) else try term_pane.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null; 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_terms[c][k]]) |compact| { column_ids[c][ids_len] = compact; ids_len += 1; }; if (ids_len > 0) { columns[columns_len] = .{ .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), .panes = column_ids[c][0..ids_len], }; columns_len += 1; } } 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, .theme = p.theme().name, .columns = columns[0..columns_len], .panes = panes[0..panes_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); } /// Initialize from another instance's dump: panes reconstructed (terminals /// by replaying their raw VT streams into fresh emulators), no spawns — /// loaded terminals are dead replays, scrollable and selectable. pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*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, .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(); const st = try dump.readZon(gpa, zon_bytes, "load"); defer dump.free(gpa, st); 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.?; const restored = try term_pane.restore(p, src); p.installPane(i, restored); p.setCwd(i, t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; break :terminal restored; }, .file => try file_pane.restore(p, i, src), .image => restore_image: { const im = src.image.?; if (pdf_pane.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 (pdf_pane.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 restored = try p.newDocPane(i); restored.file = .{ .path = path, .content = content }; restored.cur_pinned = true; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); break :restore_image restored; } break :restore_image try image_pane.restore(p, i, src); }, }; p.restoreDumpTail(pane, src); pane.vweight = src.vweight; } p.ncol = @min(st.columns.len, MAX_COLS); for (st.columns[0..p.ncol], 0..) |col, c| { 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_terms[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); p.panel_animation_enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture 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); // Early dumps put tty mode in the live prefix itself. The cwd stored // beside the replay stream is the stable half of that historical // prefix; use it to recover both custom tails and defaults even though // today's tag has only the mode box plus cwd. Check this before the // current prefix because a not-yet-reported cwd is legitimately empty // and therefore a prefix of every saved tag. 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 (image_pane.legacySavedPrefix(state, src.tag)) |legacy| return p.restoreTailAt(pane, src.tag, legacy, .generic); if (pdf_pane.isPath(saved.path)) if (pdf_pane.legacySavedPrefix(saved.path, src.tag)) |legacy| p.restoreTailAt(pane, src.tag, legacy, .generic); } /// Compatibility path for dumps without explicit tag_tail: recover what /// followed the rendered prefix, upgrading every historical default while /// retaining genuinely edited bytes. 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); } /// Which family of historical defaults a saved tail is read against. An /// output buffer is its own class rather than a file: it wears the file /// tail today, but the tail it was DUMPED with was the generic one, and a /// real file must not inherit that recognition (see restoreTailAt). 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; } /// A dirty marker may be present in the rendered compatibility tag of an /// untouched file. It is live prefix chrome, not a custom command tail; /// consume it while recovering old dumps so it disappears after Save. Only /// a pane with a file to be dirty AGAINST ever rendered one — a real file, /// or the scratch that is becoming one. 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..]; // A saved tag carries its layout gap, because the padding is real // characters — and the pane it is restored into is very often a // different width than the one it was dumped from. So compare what the // tail SAYS and not where it sat: leading spaces are layout, never // content, and a default that came back padded is still a default. const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail }, .file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, // An output buffer wore the GENERIC default until Save reached it, // and a terminal's has now been through three shapes; both upgrade // from that family. The scratch is an output buffer that wore the // FILE defaults all along (it was the one that could Save), so its // row carries both. Recognition stays scoped per class: the // generic default sitting on a real FILE is still text its owner // typed and is still kept. .output => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail, }, .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, 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; } fn removePane(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; p.absorbVWeight(id); p.layoutRemove(id); p.deinitPane(pane); p.panes[id] = null; if (p.active == id) p.active = p.prevFocus(id) orelse { p.quit = true; p.emit(.quit); return; }; } /// where focus falls when the active pane closes: the most recently /// focused pane still alive (else any live one). Null = nothing left. /// Walks the jump stack newest-first, so it answers exactly what it always /// did — a pane's newest entry sits where the old MRU put the pane. 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; } /// THE PUSH RULE, and the only place it is written down. /// /// A location is worth remembering when you cannot see it any more: focus /// ended this update in a DIFFERENT pane, or more than a bodyful of rows /// away in the same one. Anything closer is the cursor strolling, and the /// current entry just follows it — so h/j/k/w/b never grow the list, while /// a goto-line, a search hit, a goto-definition and every focus change do. /// (Vim's rule is a hand-kept list of "jump commands"; this one asks the /// question those commands are a proxy for, and needs no list.) /// /// It is read HERE, once per update, and nowhere else: what a call site /// does transiently is invisible, which is what keeps n/N over a results /// buffer — which focuses each hit and comes straight back — from pushing /// two entries per keystroke. That transparency is the whole reason the /// rule lives in sync() rather than at the sites that move focus, which is /// where the heuristics used to be scattered. /// The same PLACE, which is the one question the push rule asks: the same /// live pane, and near enough within it that the cursor was only strolling. /// Distance is a bodyful because that is what "you cannot see it any more" /// means on a screen. A location with no line — an unpinned shell, whose /// cursor belongs to the program — has no distance to be at. 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 { // dead entries first, in one compacting pass. A slot is reused, so the // test is the SERIAL: an entry whose pane has been replaced names a // pane that no longer exists, not the newcomer sitting in its slot. 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; // An UNPINNED cursor belongs to the program on the other end of the // pty, not to you, so such a pane is remembered as a place and not as // a spot: line 0 is the "no line" focusPaneLine already understands, // and going back there focuses the shell without dragging its view up // to scrollback row 0. It also has no line to be FAR from, which is // what keeps the first keypress in a shell (which pins the cursor // wherever the prompt is) from reading as a jump. 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; // ...and neither is a hop STRAIGHT BACK to the entry under this one. // That is not two jumps, it is the same two places again: Esc between a // doc and its shell, `SPC w k` / `SPC w j`, clicking back and forth. // Appending would grow the stack by one per press until the ping-pong // is the only thing it remembers — sixty-four presses and every older // place is gone. // // So SWAP the two instead of appending, rather than the other obvious // move of leaving them alone and walking jcur back down onto the older // one. The stack has a second job: it is also the focus history, and // prevFocus, Last and the look order all read it backwards on the // promise that it "runs least-recent -> active". Parking jcur mid-array // leaves the pane you are IN somewhere other than the top and quietly // breaks all three — a look would resolve against the directory of the // pane you just left before the one you are in. Swapping keeps the // promise, keeps the length, and leaves Ctrl-o stepping out past both. 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; } /// Ctrl-o / Ctrl-i (the Back and Forward builtins): move the CURSOR into /// the stack and go to what it names. Nothing is pushed and nothing is /// dropped — walking history is not making it — and trackJump agrees, /// because after the move the live spot IS `jumps[jcur]` again. 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; p.jcur = @intCast(next); const j = p.jumps[p.jcur]; p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }); } /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { p.reapPanes(); p.computeGeom(); p.syncPanelAnimations(); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; if (comptime terminal_panes) if (pane.reply_len > 0) { var off: u16 = 0; while (off < pane.reply_len) { const n = @min(pane.reply_len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pane.reply[off .. off + n]) } }); off += n; } pane.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 // a pane shrunk to just its tag keeps its last real grid: no // pty/vt reflow while the body is hidden, so re-enlarging brings // it back exactly as it was 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()) { term_pane.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; } term_pane.releasePendingCommandIfReady(p, id, pane); // Greet only after the real size AND OSC 133 B: arbitrary startup // output (or OSC A plus a prompt drawn in pieces) does not prove // readline owns echo, and injecting there leaves `ls` unmarked. if (pane.greet and pane.isTerminal() and p.resize_count > 0 and term_pane.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; } } } fn panelBox(rect: Rect) panel_animation.Box { return .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; } /// Turn one committed layout into backend-neutral transition records. /// Layout remains authoritative and takes effect immediately; these tracks /// are presentation data only, so disabling an effect cannot strand stale /// geometry or alter hit testing. fn syncPanelAnimations(p: *Pardes) void { const initializing = !p.layout_snapshot_ready or !p.panel_animation_enabled; if (initializing or p.snap_panel_layout_once) { const had_presented_layout = p.layout_snapshot_ready; p.layout_snapshot = @splat(null); p.panel_tracks = @splat(null); p.nclosing_panel_tracks = 0; p.panel_diff_pending = false; p.panel_diff_ready = false; // Resize/direct-manipulation snaps still wait for their backend // presentation acknowledgement. Clearing here would make input // follow canonical geometry while the last submitted pixels were // still animated. Initialization has no prior frame to preserve. if (initializing) { p.presented_panel_tracks = @splat(null); p.npresented_closing_panel_tracks = 0; } for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; } p.layout_snapshot_ready = true; p.snap_panel_layout_once = false; if (had_presented_layout and p.panel_presentation_ready) p.panel_presentation_pending = true; return; } const effect = p.settings.panel_transition; if (effect.needsPreviousGrid() and (p.panel_diff_pending or p.panel_diff_ready)) { var second_change = false; for (p.panes, p.layout_snapshot, 0..) |slot, snapshot, id| { const pane = slot orelse { second_change = second_change or snapshot != null; continue; }; const target = panelBox(p.rects[id]); second_change = second_change or snapshot == null or snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); } if (second_change) { // One frozen old grid cannot honestly describe two overlapping // generations. Snap rapid layout churn instead of rewinding a // new opener or sliding stale survivor cells as a tombstone. p.abandonPanelAnimations(); p.layout_snapshot = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; } return; } } var changed = false; var animated_change = false; for (p.panes, 0..) |slot, id| { const pane = slot orelse { if (p.layout_snapshot[id]) |old| { changed = true; if (effect.lifecycleOnly()) { if (p.appendClosingPanelTrack(.{ .serial = old.serial, .pane = @intCast(id), .phase = .closing, .effect = effect, .from = old.box, .to = panel_animation.closingBox(effect, old.box), })) animated_change = true; } } p.layout_snapshot[id] = null; p.panel_tracks[id] = null; // Never let pixels from a dead pane address a reused slot. p.presented_panel_tracks[id] = null; continue; }; const target = panelBox(p.rects[id]); const previous = p.layout_snapshot[id]; p.layout_snapshot[id] = .{ .serial = pane.serial, .box = target }; if (effect == .off) { changed = changed or previous == null or previous.?.serial != pane.serial or !previous.?.box.eql(target); p.panel_tracks[id] = null; continue; } if (previous) |old| { if (old.serial == pane.serial and old.box.eql(target)) continue; const same_lifetime = old.serial == pane.serial; const prior = if (p.panel_tracks[id]) |track| if (track.serial == pane.serial and track.active()) track else null else null; if (effect.lifecycleOnly() and same_lifetime) { // A vertical lifecycle transition deliberately leaves // every survivor at canonical geometry. A still-opening // lifetime remains an opener when another layout commit // retargets it; an established survivor gets no record. if (prior) |active| if (active.phase == .opening) { var next = active; next.from = panel_animation.openingBox(effect, target, p.screen_w); next.to = target; p.panel_tracks[id] = next; changed = true; animated_change = true; continue; }; p.panel_tracks[id] = null; changed = true; continue; } if (effect.lifecycleOnly() and !same_lifetime) { _ = p.appendClosingPanelTrack(.{ .serial = old.serial, .pane = @intCast(id), .phase = .closing, .effect = effect, .from = old.box, .to = panel_animation.closingBox(effect, old.box), }); } const shown = if (p.presented_panel_layout[id]) |snapshot| if (snapshot.serial == pane.serial) snapshot.box else null else null; const from = if (!same_lifetime) panel_animation.openingBox(effect, target, p.screen_w) else if (shown) |box| box else if (p.panel_presentation_ready and prior != null) prior.?.from else old.box; const next: panel_animation.Track = .{ .serial = pane.serial, .pane = @intCast(id), .phase = if (!same_lifetime or (prior != null and prior.?.phase == .opening)) .opening else .moving, .effect = effect, .from = from, .to = target, }; p.panel_tracks[id] = next; changed = true; animated_change = true; } else { const next: panel_animation.Track = .{ .serial = pane.serial, .pane = @intCast(id), .phase = .opening, .effect = effect, .from = panel_animation.openingBox(effect, target, p.screen_w), .to = target, }; p.panel_tracks[id] = next; changed = true; animated_change = true; } } if (animated_change and effect.needsPreviousGrid()) { p.panel_diff_pending = true; p.panel_diff_ready = false; } if (changed and p.panel_presentation_ready) p.panel_presentation_pending = true; } fn appendClosingPanelTrack(p: *Pardes, track: panel_animation.Track) bool { std.debug.assert(track.phase == .closing); const baseline = p.presented_cells_layout[track.pane] orelse return false; if (!p.presented_cells_valid or baseline.serial != track.serial or !baseline.box.eql(track.from)) return false; if (p.nclosing_panel_tracks == p.closing_panel_tracks.len) { // Bounded presentation history: under pathological delete/reuse // churn, retire the oldest (and therefore furthest-progressed) // tombstone rather than retaining a pane or allocating per Del. std.mem.copyForwards( panel_animation.Track, p.closing_panel_tracks[0 .. p.closing_panel_tracks.len - 1], p.closing_panel_tracks[1..], ); p.nclosing_panel_tracks -= 1; } p.closing_panel_tracks[p.nclosing_panel_tracks] = track; p.nclosing_panel_tracks += 1; return true; } fn advancePanelAnimations(p: *Pardes) void { for (&p.panel_tracks) |*slot| { const track = if (slot.*) |*track| track else continue; track.frame +|= 1; if (!track.active()) slot.* = null; } var out: usize = 0; for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |value| { var track = value; track.frame +|= 1; if (!track.active()) continue; p.closing_panel_tracks[out] = track; out += 1; } p.nclosing_panel_tracks = out; } fn columnBoundary(width: u16, prefix: u128, total: u128) u16 { if (total == 0) return 0; const pixels = (@as(u128, width) * prefix + total / 2) / total; return @intCast(@min(@as(u128, width), pixels)); } pub fn computeGeom(p: *Pardes) void { if (p.ncol == 0) return; var wsum: u128 = 0; for (0..p.ncol) |c| wsum += p.col_weight[c]; if (wsum == 0) wsum = 1; // Round cumulative boundaries, not each width independently. Splitting // one weight W into A+B=W then leaves every boundary before A and // after B bit-identical, at every screen width; independent rounding // can move a later column by one cell even though its own weight and // the total did not change. var prefix: u128 = 0; for (0..p.ncol) |c| { const last = c + 1 == p.ncol; const x = columnBoundary(p.screen_w, prefix, wsum); prefix += p.col_weight[c]; const end: u16 = if (last) p.screen_w else columnBoundary(p.screen_w, prefix, wsum); const cw = end -| x; p.col_x[c] = x; p.col_w[c] = cw; var vsum: f32 = 0; for (0..p.col_n[c]) |k| { if (p.panes[p.col_terms[c][k]]) |pane| vsum += pane.vweight; } if (vsum <= 0) vsum = 1; var y: u16 = TOPBAR_H; const avail_h = p.screen_h -| TOPBAR_H; for (0..p.col_n[c]) |k| { const id = p.col_terms[c][k]; const pane = p.panes[id] orelse continue; const lastk = k + 1 == p.col_n[c]; const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; // every pane wants at least one row, so a column with more // panes than the window has rows would walk `y` off the bottom // and hand renderPane a rect outside the surface (assert, then // panic — shrink a window with a few stacked panes). Clamp to // what is left: the panes past the edge get h = 0 and render // nothing until the window grows back. const room = p.screen_h -| y; const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; y +|= ch; } } } pub fn theme(p: *const Pardes) *const Theme { if (p.custom_theme_active) return &p.custom_theme.?; 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; const scene = p.settings.scene_effects; if (scene.crt or scene.ripple or scene.glitch) return true; for (p.panel_tracks) |track| if (track != null and track.?.active()) return true; for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| if (track.active()) return true; return false; } /// Arm the pulse. Look wins a tie because a Look that runs a command /// (`` @`ls` ``, which is one gesture spelled as both) is felt as the /// thing the user asked for, not as the shell it happened to need. 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; } fn finishThemeInitialization(p: *Pardes) void { p.chrome_animation.snap(ChromeTheme.fromTheme(p.theme())); p.animate_theme_changes = true; } fn invalidateThemeDependentRasters(p: *Pardes) void { if (comptime pdf_enabled) for (p.panes) |slot| { const pane = slot orelse continue; const pv = &(pane.pdf orelse continue); if (pv.tint != .disabled) pv.invalidateAllRasters(); }; } /// The sole live-session theme mutation path. Target colors change now; /// anchored chrome retargets from its currently displayed palette. Only /// PDFs whose pixels depend on target theme colors are marked stale, once; /// untinted source rasters remain byte-for-byte resident. pub fn setThemeIndex(p: *Pardes, index: usize) void { if (index >= themes.len or (!p.custom_theme_active and p.theme_file_path.get().len == 0 and index == @as(usize, p.settings.theme))) return; const target_chrome = ChromeTheme.fromTheme(&themes[index]); if (p.animate_theme_changes) p.chrome_animation.retarget(target_chrome) else p.chrome_animation.snap(target_chrome); if (p.custom_theme) |theme_value| { p.custom_theme = null; p.custom_theme_active = false; std.zon.parse.free(p.gpa, theme_value); } if (p.theme_file_path.get().len > 0) { p.theme_file_path.clear(); const generation = p.nextThemeFileGeneration(); p.emit(.{ .theme_file = .{ .generation = generation, .on = false } }); } p.settings.theme = @intCast(index); p.invalidateThemeDependentRasters(); } // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { file_pane.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { p.gpa.free(s.cells); s.cells = try p.gpa.alloc(Cell, ncells); } s.cols = p.screen_w; s.rows = p.screen_h; s.cursor = null; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours // gaps between panes read as chrome (scrollbar track), not raw default s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; try p.renderPane(arena, pane, p.rects[id], id, id == p.active); } // ---- the transient message row: the pane's last body row ---- // // An OVERLAY, not geometry: no rect moves, no pane shrinks, and a pane // with neither a message nor an armed prompt is not touched at all. // Drawn after every pane's body so it lands OVER whatever that row was // showing, and painted across the whole row — it is the tagline's twin, // and reading as one strip rather than a stamp on a body line is what // keeps it from being mistaken for content. Out here rather than at the // end of renderPane because renderPane returns early for a native PDF // page and for an image, and a `/` on a PDF is a real search whose // prompt has to be visible like any other. // // Exactly two things can occupy the row and an ARMED PROMPT beats a // MESSAGE, because they are not the same kind of thing: a message is a // report of what already happened and the next keystroke wipes it, a // prompt is what that keystroke is being typed into and it lives until // Enter or Esc. // // ponytail: the row is draw-only. A click on it lands wherever the body // under it says (tag clicks map to tag_col on the TAG row), so a prompt // that moved off the tagline cannot be clicked into or swept the way it // could up there — the keyboard still edits it in full. Upgrade path // is a hit test here that maps a press on this row to tag_col + the // marker offset, i.e. the tag row's own mapping with a constant added. for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; const r = p.rects[id]; // no body row (a one-row pane, or one squeezed out entirely): the // tag row is not ours to overwrite, so the message just waits. One // guard for both placements, because the row picked below is the // last BODY row either way — "the pane has a body row" is the whole // condition, and it is what keeps r.h - 1 - BOX_H from underflowing // or landing above the pane. if (r.w <= config.GUTTER or r.h <= BOX_H) continue; // the same one input model renderPane cut off the tagline; only one // of the two can ever be armed (a body key arms one, exitTagEdit // clears both) const prompt_at = pane.promptAt(); const text = if (prompt_at) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..] else pane.msg[0..pane.msg_len]; // The pending SPC path is the third thing that wants this row, and // it can want it while the row is otherwise empty. const leader_here = id == p.active and p.leader_on; if (text.len == 0 and !leader_here) continue; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; // the pane's last BODY row: its last row outright, or one up from // that when Tagbottom has taken the last for the tagline. The first // cut of Tagbottom sent this row to the pane's FIRST instead — the // far end, symmetric with the tag — which put the prompt you are // typing as far as the pane allows from the tag you are typing // into. Beside the tagline is where it is read, so it stays there. const row = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1; // In the EDITOR's colours, not the tag bar's: this row is the one // place the program talks back to you about the buffer you are in, // and it reads as part of that buffer rather than as another strip // of chrome. th and not chrome for the same reason a selection // uses th — it is not attached to any geometry, so it arrives with // the theme instead of sliding in over the chrome animation. const msg_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; // the WHOLE row, the way the tagline fills its own before printing: // a message is a section and not a stamp, and print writes only the // cells it needs — so without the fill the body row shows through // to the right of a short message and reads as one garbled line. s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg }); if (text.len > 0) _ = s.print(tx, row, tw, text, msg_style); // The pending SPC leader path, right-aligned. It used to sit on the // active pane's tagline, where it had to fight the builtins in the // tail for the same columns; down here it is beside the rest of the // transient state, and printed AFTER the message so a long one // loses its last columns rather than hiding what you are typing. if (leader_here) { var ibuf: [16]u8 = @splat(' '); @memcpy(ibuf[1..4], "SPC"); var iw: usize = 4; for (p.leader_keys[0..p.leader_n]) |ch| { ibuf[iw + 1] = ch; iw += 2; } const w: u16 = @intCast(iw); if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style); } // ...and the cursor follows the text it edits. tag_col is a byte // offset, so the prompt maps its suffix through display widths; a // cursor LEFT of the marker is still over the part // of the tag that stayed on the tagline, and the tag cursor // renderPane already placed there is the right one. if (id != p.active) continue; const at = prompt_at orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; const col = @as(usize, pane.tag_col); if (col >= prompt0) { const prompt_col = file_pane.displayWidth(text[0..@min(col - prompt0, text.len)]); if (prompt_col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; } } // global tagbar: full width, top row s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, // The blank tail is the same visible band as the printed words. // SDL used to repair this at draw time by special-casing row zero, // but native hosts consume the role carried by each cell. .font_role = .tagline, }); var tb_buf: [1200]u8 = undefined; _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); // The topbar is executable chrome, just like a button row. Pane text // already previews the exact operand a Look would use; row zero has // no Pane and used to fall through that machinery without any pointer // feedback at all. Paint the same word the click dispatcher resolves, // immediately, while leaving whitespace inert. if (p.pointer_inside and p.hover_row < TOPBAR_H) { const bar = p.topbar(&tb_buf); if (wordBoundsAtCol(bar, file_pane.rawAtDisplay(bar, p.hover_col))) |bounds| { var col = file_pane.rawDisplayCol(bar, bounds.lo); const hi = file_pane.rawDisplayCol(bar, bounds.hi); 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 }; } } } // the topbar's cursor, if it has the keyboard. AFTER the pane loop on // purpose: there is exactly one Surface cursor and the bar's must beat // the active pane's. Always a block — the bar has no insert mode. if (p.topbar_col) |c| { const col = file_pane.rawDisplayCol(p.topbar(&tb_buf), c); if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = false }; } // resize-handle hint / drag previews: a dash overlay that keeps the // underlying colors (border drags + hover), or the move indicator. A // drag holds the coordinates of the last mouse event, so a resize // mid-drag (tiling WM, font-size change) can leave them off the new // surface — every arm below checks before it draws. switch (p.drag) { .border_v => |d| { if (d.cur_x < s.cols) { var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); } // a corner lights BOTH splits, which is the whole tell that you // grabbed the crossing and not an edge. The horizontal half runs // across ITS OWN column and stops at the vertical preview rather // than at that column's current edge, so the two dashes stay // joined at the cell under the mouse: through the handle for a // left-column corner, butting into it from the right neighbour // for a right-column one. 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, "╌"); }, .move => |d| if (d.cur_x < s.cols) { if (p.movePlacement(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 = TOPBAR_H; 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, "╌"); } } // the containment test above can only ever light the column the // hovered cell is IN, and a v handle is its LEFT column's cell. // So when that column has no seam here but its right neighbour // does, light the neighbour's: that is the corner a press would // take (Drag.border_v), and a grabbable crossing has to be // visible before it is grabbed. 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 = file_pane.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 = term_pane.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } // The face belongs to the SHELL, so the core can only name the one // it was asked for; nothing has asked when this is empty and the // shell is still in whatever it booted in. Clamped because a name // is a file stem and a path component can be as long as a path. 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 TOPBAR_H + oh <= s.rows) { // vaxis single_rounded border ring, default style s.set(ox, TOPBAR_H, "╭", .{}); s.set(ox + ow - 1, TOPBAR_H, "╮", .{}); s.set(ox, TOPBAR_H + oh - 1, "╰", .{}); s.set(ox + ow - 1, TOPBAR_H + oh - 1, "╯", .{}); var bx = ox + 1; while (bx < ox + ow - 1) : (bx += 1) { s.set(bx, TOPBAR_H, "─", .{}); s.set(bx, TOPBAR_H + oh - 1, "─", .{}); } var by = TOPBAR_H + 1; while (by < TOPBAR_H + oh - 1) : (by += 1) { s.set(ox, by, "│", .{}); s.set(ox + ow - 1, by, "│", .{}); } s.fill(ox + 1, TOPBAR_H + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } }); var lines = std.mem.splitScalar(u8, text, '\n'); var ly: u16 = TOPBAR_H + 1; while (lines.next()) |line| : (ly += 1) { if (ly >= TOPBAR_H + oh - 1) break; _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } }); } } }; p.submitted_panel_layout = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; p.submitted_panel_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]), }; } p.submitted_panel_layout_ready = true; // The old grid stays frozen for the transition, while terminals, // watches, hover chrome, and other live data may change the new grid // between samples. Rebuild the cheap byte mask every frame so the // published (old, new, changed) triple is always coherent. if ((p.panel_diff_pending or p.panel_diff_ready) and !try p.preparePanelDiff()) { // There was no successfully presented same-sized old grid. A // content effect cannot guess one: snap this transition rather // than animating uninitialised/stale cells. for (&p.panel_tracks) |*slot| { const track = slot.* orelse continue; if (track.effect.needsPreviousGrid()) slot.* = null; } p.nclosing_panel_tracks = 0; p.panel_diff_pending = false; p.panel_diff_ready = false; } if (p.panel_diff_ready) { s.previous_cells = p.presented_cells; s.cell_diffs = p.panel_cell_diffs; } // Moving panes first, then new panes, then inert closing tombstones on // top. Native GUI paint planners may regroup by phase, but every host // receives this same deterministic dense record set. for ([_]panel_animation.Phase{ .moving, .opening }) |phase| for (p.panel_tracks) |maybe| { const track = maybe orelse continue; if (!track.active() or track.phase != phase) continue; s.panel_tracks[s.npanel_tracks] = track; s.npanel_tracks += 1; }; for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| { if (!track.active()) continue; s.panel_tracks[s.npanel_tracks] = track; s.npanel_tracks += 1; } return p.composeAsciiTransitions(arena, s); } // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN /// Lazily copy the canonical grid only when an active core-composed /// character track still shows something other than its final glyph. The /// returned Surface is the sole backend boundary, so every shell rasterizes /// the exact same intermediate characters and style-only/non-ASCII changes /// pass through once. 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 = panel_animation.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; } /// The character one cell presents this frame, or null when the canonical /// cell is already the honest answer. PanelAscii walks the semantic byte /// distance of a *changed* cell; the motion effects carry every glyph in /// the pane, because text flying in from a screen edge has to bring its /// unchanged glyphs along with it. fn composedCell( canonical: *const Surface, track: panel_animation.Track, area: panel_animation.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; // Frame zero is the exact old cell. Once a byte is walking, the // semantic destination owns presentation style, and at the endpoint // the untouched canonical cell wins bit-for-bit instead. if (track.frame == 0) return canonical.previous_cells[index]; return withByte(canonical.cells[index], byte); } return switch (panel_animation.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 preparePanelDiff(p: *Pardes) !bool { const count = p.surface.cells.len; if (!p.presented_cells_valid or p.presented_cells_cols != p.surface.cols or p.presented_cells_rows != p.surface.rows or p.presented_cells.len != count) return false; if (p.panel_cell_diffs.len != count) { const next = try p.gpa.alloc(PanelCellDiff, count); if (p.panel_cell_diffs.len > 0) p.gpa.free(p.panel_cell_diffs); p.panel_cell_diffs = next; } for (p.panel_cell_diffs, p.presented_cells, p.surface.cells) |*diff, *old, *new| diff.* = PanelCellDiff.between(old, new); // The fixed Track ABI already carried two padding bytes after frame. // They now hold the core-computed ASCII duration: exactly one sample // beyond the longest eased walk in this pane, so there is neither a // forced endpoint jump nor a long invisible tail for nearby glyphs. for (&p.panel_tracks) |*slot| { const track = if (slot.*) |*track| track else continue; if (track.effect != .ascii) continue; var longest: u16 = 1; var row: u16 = 0; while (row < p.surface.rows) : (row += 1) { var col: u16 = 0; while (col < p.surface.cols) : (col += 1) { if (!boxContainsCell(track.to, col, row)) continue; const index = @as(usize, row) * p.surface.cols + col; switch (p.panel_cell_diffs[index]) { .ascii => |diff| longest = @max(longest, diff.frameCount()), .unchanged, .visual => {}, } } } track.frame_count = @max(track.frame_count, longest); } p.panel_diff_pending = false; p.panel_diff_ready = true; return true; } // EFFECT_CODE_ASCII_COMPOSITOR_END /// Paint a selection expressed in the coordinate space used by pointer /// gestures. Both the persistent mouse selections and the delayed Look /// preview come through this one clipping/mapping path. fn paintPointerSelection( s: *Surface, pane: *const Pane, r: Rect, tx: u16, tw: u16, tag_y: u16, body_y: u16, sl: Sel, bg: [3]u8, fg: ?[3]u8, ) void { const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0 = @max(0, @min(sl.c0, sl.c1)); const c1 = @max(0, @max(sl.c0, sl.c1)); var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; // A Sel row is independent of Tagbottom: zero is the tag and // BOX_H upward is the body. This is the render-side inverse of // pointerTextSelection. const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; // File line numbers occupy PREFIX_W only in the body. The tag is // row zero in Sel space and starts at its real first text cell. var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, config.PREFIX_W)) else c0; while (col <= c1 and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), sy); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; } } } 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; // The pane's two anchor rows, computed once: the tagline's, and the // body's first. The Tagbottom builtin swaps which end each is at and // NOTHING else in here reads r.y — that is the whole of the feature on // the render side. r.h == 0 returned above, so the bottom row exists. const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; // the pane's own background, for everything that has to read as "no // chrome here": the body text, and the blank right half of the // scrollbar's second column. `.default` means the host terminal's own // background, which is what a themeless dark pane wants. const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; // ...and the same background as a colour to do arithmetic on, which the // dimmed selections below need. A theme with a null bg cannot say what // the host's own cell looks like, so its tag bar stands in — the // substitution pdf_pane.tintColors already makes. const page_rgb = th.bg orelse th.tag_bg; // text area resets to terminal-default cells (vaxis clear semantics); // light themes paint the page over it. s.clearRect(tx, r.y, tw, r.h); if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); // the layout box: the pane's MODE, one character, in the gutter cells // of the tag row. Same box you drag a pane by — the whole GUTTER is // still painted and the `.move` press hit-test in handleMouse is // untouched — it just carries the one piece of state that used to cost // four columns of every tagline. // The glyph goes in column 0, directly above the scrollbar's ink // column below it, so a pane's chrome reads as one line down its left // edge; column 1 stays plain colour, and that blank half is what keeps // the thing reading as a BOX rather than as a letter someone dropped // in the gutter. // // The mode shown is the BODY's. A tag edit hijacks pane.mode to insert // (tags are always insert; the real one is parked in tag_mode), and a // badge that flipped every time you clicked a tagline would be // reporting the tag's mode on the pane's box. Reading tag_mode also // makes the parked value VISIBLE: a terminal being tag-edited still // shows `$`, which is exactly the invariant ttyclick.snap exists for. // // The ink is picked off the box's own brightness instead of being // named in the theme, because box/box_dim come from a generated // theme's cursor and selection colours — light on a light theme — and // a fixed white would vanish there. Rec.601-ish integer weights. // ponytail: two colours, black or white, chosen at a fixed threshold. // Upgrade to the theme's own fg/bg pair when a theme turns up whose // box wants a tint rather than a contrast. 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 }, // The mode badge is the left end of the tagline, not body text. // Carry the same role so every GUI backend scales the whole row. .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); // tag: live prefix (cwd/path) + editable tail, on a dim bar const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.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(tx, tag_y, tw, BOX_H, .{ .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); const tag = try p.tagText(arena, pane); // An armed input — `/`, Find, Grep, Rename, WsSymbols, Select/Split, // `|` — is still TYPED INTO the tag tail: same buffer, same offsets, // same one-line modal editor, same Enter and same Esc. Only where it is // DRAWN moved. It is cut off the tagline here and printed on the pane's // message row instead (see render), so the builtins in the tail stay // readable while you type instead of being pushed off the right edge by // a long pattern. // // The prompt offset is in the tail while tag_col is in the rendered // tag, so add the live prefix length when clipping the editable span. const prompt_at = pane.promptAt(); const tag_cut = if (prompt_at) |at| @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; _ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style); // Paint tag hover before every real tag selection. This common point // is above the native-PDF/image early returns, so their tag operands // are no longer hidden, while an explicit sweep remains authoritative. 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 = mix(page_rgb, mix(page_rgb, th.sel_bg)); paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } // tag char selection highlight (helix v/x, or a tagline sweep), // inclusive [lo, hi], mapped from byte offsets to display cells. // // Every selection on screen paints in the LIVE theme (`th`) and not in // `chrome`: a highlight is not attached to any geometry, it appears // under the range you just swept, so it has to arrive with the theme // the way syntax colours do rather than slide in over the chrome // animation's frames. if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); var col = file_pane.rawDisplayCol(tag, b.lo); const hi = modal.nextGrapheme(tag, b.hi); const end = file_pane.rawDisplayCol(tag, hi) -| 1; while (col <= 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 = file_pane.rawDisplayCol(tag, pane.tag_col); if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } // A native PDF page uses the same backend-neutral pixel attachment as // an image. Without native pixels it deliberately falls through: its // extracted text projection becomes an ordinary readable body. if (comptime pdf_enabled) if (hasPdf(pane) and pdf_pane.draw(p, pane, r, id, tx, tw)) return; // image pane: the picture fills the body — petscii glyph art into the // cells, or a pixel attachment the shell places (kitty). Plain // thumbless gutter so it reads like any other pane. if (pane.image) |*iv| { image_pane.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, r.h -| BOX_H, .{ .bg = pane_bg }); return; } // body: emulator rows, prompt rows hidden outside tty mode const body_h = if (r.h > BOX_H) r.h - BOX_H else 0; const body_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = pane_bg, }; const tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); tz_body.end(); var it = std.mem.splitScalar(u8, body, '\n'); var i: u16 = 0; while (it.next()) |line| : (i += 1) { if (i >= body_h) break; _ = s.print(tx, body_y + i, tw, line, body_style); } // Coloring is one algorithm per pane, chosen by title (colorAlgo): the // terminal projects its own ANSI, a file lays tree-sitter or diff // shading over its content. source and diff share this pass because // both feed f.highlights, which refreshHighlights filled with whichever // this same choice named. Order is load-bearing — gutter, recolor, then // wrap markers; the selection/cursor passes below win over all three. switch (pane.colorAlgo()) { .tty => if (p.settings.colors and pane.mode == .tty) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h), .source, .diff => { const f = &pane.file.?; file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); }, .none => {}, } // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; // middle/right .done are transient (they fire their action on release). // // Which button drew a sweep is worth seeing, so each gets the theme's // selection tinted toward one of the theme's own syntax accents: three // colours that are visibly not each other on a dark theme and on a // light one, without asking a theme to name three more. A QUARTER of // the accent, which is the mix of a mix — at a half, an accent as // bright as the theme's text lands on top of sel_fg and the selected // text stops being readable on a couple of dozen generated themes. // `num` gives way to `comment` when a theme paints numbers and strings // alike — the shipped helix theme does, 24 of the generated ones do — // because two buttons landing on one colour is the whole thing this // avoids. // ponytail: 20 of the 228 themes colour two of these three scopes the // same anyway and still collapse two buttons. Upgrade path is to walk // the theme for a third colour far enough from the other two, rather // than naming the scopes here. const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; const sel_btn = [3][3]u8{ mix(th.sel_bg, mix(th.sel_bg, th.kw)), mix(th.sel_bg, mix(th.sel_bg, th.str)), mix(th.sel_bg, mix(th.sel_bg, accent2)), }; if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) { // Preserve body ink, then let every real user selection below // win over this quiet affordance. const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| file_pane.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| if (@max(sel.r0, sel.r1) >= BOX_H) paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| preview.pane == id and preview.serial == pane.serial and preview.sel == null and preview.file_word == null else false; for (pane.sel, 0..) |sl, b| { if (sl.state == .none) continue; if (sl.state == .done and b != 0) continue; paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.mode == .normal or modal_hover) and pane.msel.active) { const hover_only = modal_hover and pane.mode != .normal; const modal_bg = if (hover_only) mix(page_rgb, mix(page_rgb, th.sel_bg)) else th.sel_bg; const lo = @min(pane.msel.r0, pane.msel.r1); const hi = @max(pane.msel.r0, pane.msel.r1); var row: u16 = BOX_H; // never paint the tag row while (row < r.h) : (row += 1) { // walked by SCREEN row and asked what LINE each one shows, // because a wrapped line is several rows. wrapAt degenerates to // `off + row` when nothing wrapped, which is what this was. const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line; if (ar < lo or ar > hi) continue; var col: u16 = if (pane.file != null) config.PREFIX_W else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, body_y + row - BOX_H); cell.default = false; cell.style.bg = .{ .rgb = modal_bg }; if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg }; } } } // modal char selection (helix `v`): stream-shaped anchor..head // highlight. The EXTRA cursors are the same shape drawn dimmer, and // each of them also paints its own cursor cell bright: there is one // hardware cursor and the primary owns it, so a secondary cursor has // to be a cell colour or it is invisible. // The extra cursors show in INSERT mode too — that is exactly when you // need to see where your typing is landing — while the primary's // selection highlight stays normal-mode-only, as it always was. // ...and an armed `s`/`S` shows the primary WHATEVER shape it is, even // though the pane is in insert mode for the tag and even when the match // is a single cell: the hardware cursor is off in the tag, so a preview // that leans on it shows every match except the one you are on. const preview = selRegexArmed(pane) != null; const show_prim = (pane.mode == .normal and pane.vsel.active) or preview or (modal_hover and pane.vsel.active); const show_extra = pane.mode != .tty and pane.nsel > 0; if (show_prim or show_extra) { const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0; var si: usize = 0; while (si <= pane.nsel) : (si += 1) { const primary = si == pane.nsel; if (if (primary) !show_prim else !show_extra) continue; const sr = if (primary) 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, } else pane.sels[si]; const bnd = cellBounds(sr); const hover_only = primary and modal_hover and pane.mode != .normal and !preview; // an extra cursor is the selection colour turned down: the // highlight pulled halfway back to the page, so the primary is // the one that reads as "here" at a glance. On a light theme // that dims toward white rather than toward black, which is the // same statement. The INK stays put — helix's two selection // styles differ in their background too, and dimming both ends // walks the text and its cell toward each other until neither // is readable on the themes whose selection is already close to // the page. const bg = if (hover_only) mix(page_rgb, mix(page_rgb, th.sel_bg)) else if (primary) th.sel_bg else mix(th.sel_bg, page_rgb); // ...and this walks SCREEN rows too, asking the map which line // and which byte column of it each one shows. Unwrapped that is // `off + vr` / `hscroll`, i.e. the arithmetic this was, and it // is also the cheaper loop: a linewise selection over a whole // file used to iterate once per LINE to reject all but a // screenful of them. var vr: i32 = 0; while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) { const w = pane.wrapAt(vr); if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; const visible_line = modal.lineSlice(body, @intCast(vr)); const cstart: i32 = if (w.line == bnd.lo_row) (if (pane.file != null) file_pane.displayOffset(pane, w.line, w.at, bnd.lo_col) else file_pane.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx else vpfx; const cend: i32 = if (w.line == bnd.hi_row) (if (pane.file != null) file_pane.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else file_pane.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx else @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); while (col <= cend and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg }; } } if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's if (hover_only) continue; // quiet preview preserves the source ink const cw = pane.wrapRow(sr.row, sr.col); const crow = cw.row + @as(i32, BOX_H); const ccol = (if (pane.file != null) file_pane.displayOffset(pane, sr.row, cw.at, sr.col) else file_pane.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cw.row))), @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), )) + vpfx; if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) { const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H))); cell.default = false; cell.style.bg = .{ .rgb = th.sel_fg }; cell.style.fg = .{ .rgb = th.sel_bg }; } } } // cursor: tracks the shell cursor until pinned by a click or a key // (the tag cursor above wins while the tag is focused) if (active and !pane.tag_edit) { const cur = term_pane.gridCursor(pane); if (pane.mode != .tty) { const goff = term_pane.gridOffset(pane); const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); // which ROW of a wrapped line the cursor is on, and which byte // column that row starts at — `off`/`hscroll` when nothing // wrapped, so this is the same two lines it always was const cwp = pane.wrapRow(crow, ccol); const prow = cwp.row + @as(i32, BOX_H); // Files store source-byte columns; the Surface stores display // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, crow, cwp.at, ccol) else if (pane.cur_pinned) file_pane.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cwp.row))), @intCast(@max(0, cwp.at)), @intCast(@max(0, ccol)), ) else ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.mode == .insert }; } else if (cur.y + BOX_H < r.h and cur.x < tw) { s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y, .bar = pane.mode == .insert }; } } // gutter below the tag row: scrollbar track + thumb. (The move box on // the tag row itself is drawn up with the tag, since it now carries // the mode and belongs with the rest of that row.) // // The bar is ONE column: column 1 is the track/thumb, column 2 is the // pane's own background. That second fill is not optional — render() // pre-fills the whole surface with scroll_track so the gaps between // panes read as chrome, so a column left unpainted here keeps the // track colour and the bar looks two wide again. // // The move box above stays the full GUTTER even though only half of it // has ink in it. It is a target you drag, not a gauge you read, and its // whole width is the affordance — the step where the box ends and the // narrower bar begins is the one place on screen that says those are // two different pieces of chrome. // // Nothing here touches layout or hit-testing: the gutter is still // config.GUTTER columns and the click handlers still scroll on any of // them, so the blank column is still live. Only the ink narrowed. if (r.h > BOX_H) { s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ .total = file_pane.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else if (pane.pdfPage()) |page| .{ .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, .offset = page, .len = 1, } else blk: { const gsb = term_pane.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = r.h - BOX_H; const total = if (sb.total == 0) 1 else sb.total; const len = @max(1, (track_h * @max(1, sb.len)) / total); const pos = (track_h * sb.offset) / total; var sy: usize = pos; while (sy < track_h and sy < pos + len) : (sy += 1) { s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); } } } 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; } /// The pane body as text. Image: blank rows (the picture draws over it). /// File: line-numbered content from f.scroll. /// Terminal: viewport rows, padded to the grid height, prompt rows blanked /// outside tty mode (OSC 133), the edit buffer standing in for the shell /// rows it covers — which is where a buffer holding more lines than those /// rows pushes the output below it down the screen. fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.image != null) { const buf = try arena.alloc(u8, pane.rows -| 1); @memset(buf, '\n'); return buf; } if (hasPdf(pane)) { if (comptime pdf_enabled) return pdf_pane.visibleText( &pane.pdf.?, p.pdf_gpa, arena, pane.rows -| BOX_H, ); unreachable; } if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.settings.wrap); return term_pane.bodyText(arena, pane); } }; test "Esc alternates between two panes of the SAME kind" { if (platform == .web) return; const gpa = std.testing.allocator; // An ABSOLUTE boot path, the way main.zig resolves argv: a file pane's // directory is its path's dirname, and a relative one leaves nothing for // the look below to resolve against. 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; // A second FILE. This is the case the doc<->terminal hop Esc used to run // could not do AT ALL: both panes are docs, so it had nothing of "the other // kind" to reach and Esc did nothing. 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 "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(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.vt.screens.active.pages.scrollbar().offset; try std.testing.expect(live > 0); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // 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); // The prompt is still on screen. Restoring the stale pin used to yank the // view up to scrollback row 0, where it sat until the next keystroke's echo // scrolled it back down — "type something and the tty jumps to the prompt". try std.testing.expectEqual(live, sp.vt.screens.active.pages.scrollbar().offset); } test "Shift-Esc in tty hops to the doc and leaves the shell in tty" { 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 shell_pane = p.panes[shell].?; try std.testing.expect(shell_pane.isTerminal()); // Shift-Esc still gets you IN, exactly as the configured Ctrl-key does. const shift_esc: Key = .{ .cp = Key.escape, .shift = true }; p.update(.{ .key = shift_esc }); try std.testing.expectEqual(Mode.tty, shell_pane.mode); // ...and out of tty it is Escape-in-normal-mode instead of a toggle: the // doc takes focus and the shell KEEPS its tty mode, so coming back lands // in the program you left rather than in normal mode on top of it. p.update(.{ .key = shift_esc }); try std.testing.expect(p.active != shell); try std.testing.expect(!p.panes[p.active].?.isTerminal()); try std.testing.expectEqual(Mode.tty, shell_pane.mode); // The configured Ctrl-key is now the only thing that leaves tty in place. p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // back to the shell try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Mode.normal, shell_pane.mode); try std.testing.expectEqual(shell, p.active); } 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); // The collapse must not eat history: Back still walks OUT of the ping-pong // to the place before it, which is what makes the entry a cursor move // rather than a deletion. 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); } /// Drain the queue and say whether the shell was asked to do `want`. fn drainedEffect(p: *Pardes, want: std.meta.Tag(Effect)) bool { var seen = false; while (p.nextEffect()) |effect| { if (std.meta.activeTag(effect) == want) seen = true; } return seen; } test "only the SPC clipboard commands cross to the system clipboard" { 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 } }); const pane = p.panes[0].?; // An ordinary yank fills the DEFAULT REGISTER and asks the shell for // nothing. This is the whole helix split, and the bug it closes: before // it, every y/d/c mirrored out, so deleting one character threw away // whatever the desktop was holding. _ = drainedEffect(p, .set_clipboard); p.update(.{ .key = .{ .cp = 'y' } }); try std.testing.expect(p.yank != null and p.yank.?.len > 0); try std.testing.expect(!drainedEffect(p, .set_clipboard)); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expect(!drainedEffect(p, .set_clipboard)); // `SPC y` is the one that does, and it puts the SAME text there that `y` // put in the register — it is the ordinary yank path plus the mirror. p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'y' } }); const yanked = p.yank orelse return error.MissingYank; try std.testing.expect(drainedEffect(p, .set_clipboard)); // `SPC p` cannot read the clipboard itself: it ASKS, and the answer comes // back as an ordinary paste event whenever (or never — a terminal may // refuse the OSC 52 read, which is a no-op and not a hang). const before = pane.file.?.content.len; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'p' } }); try std.testing.expect(p.clip_pending != null); try std.testing.expect(drainedEffect(p, .read_clipboard)); try std.testing.expectEqual(before, pane.file.?.content.len); // nothing yet p.update(.{ .paste = "PASTED" }); try std.testing.expect(p.clip_pending == null); try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null); // ...and it did NOT land in the register on its way past, nor echo back // out to the clipboard it came from. try std.testing.expectEqualStrings(yanked, p.yank orelse ""); try std.testing.expect(!drainedEffect(p, .set_clipboard)); // An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V — // is the same event with no request behind it, and means paste after. p.update(.{ .paste = "UNASKED" }); try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null); try std.testing.expectEqualStrings(yanked, p.yank orelse ""); // A request the shell never answers dies at the next keystroke rather // than firing late into whatever pane is focused by then. p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'P' } }); try std.testing.expect(p.clip_pending != null); p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expect(p.clip_pending == null); } /// 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 "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { 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].?; term_pane.enterTty(p, 0); try std.testing.expectEqual(Mode.tty, pane.mode); p.setYank("one\ntwo"); // Ctrl-V types the DEFAULT REGISTER at the program. Unbracketed, so the // newline becomes Enter's \r — a raw \n would run `one` and leave `two` // half-typed. p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); try std.testing.expectEqualStrings("one\rtwo", drainWrites(p, &buf)); // and it asked the desktop for nothing on the way try std.testing.expect(p.clip_pending == null); // Under mode 2004 the same keystroke brackets instead, which is what stops // readline from RUNNING a multi-line paste. pane.vt.modes.set(.bracketed_paste, true); p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", drainWrites(p, &buf)); // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and the // answer lands at the program rather than in an edit buffer. pane.vt.modes.set(.bracketed_paste, false); p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); try std.testing.expect(p.clip_pending != null); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); p.update(.{ .paste = "from-desktop" }); try std.testing.expectEqualStrings("from-desktop", drainWrites(p, &buf)); // the clipboard did not clobber the register on its way through try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); } 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].?; term_pane.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 "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 = term_pane.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 = term_pane.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, ); // Out of tty the prompt goes and the command stays — LEFT-HUGGED, so it // lines up with the output below instead of sitting in a bay of blanks // where the prompt used to be. sp.mode = .normal; try std.testing.expectEqualStrings( "grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); // The MOTION SURFACE cuts either way, and deliberately: it is what the // cursor moves over, and in tty mode nothing moves over it — the keys all // belong to the program. 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 term_pane.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()) |_| {} term_pane.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); }