From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- src/pardes.zig | 8549 ++++++++++++-------------------------------------------- 1 file changed, 1734 insertions(+), 6815 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 6edf3600..8d5f0ba7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1,165 +1,49 @@ -//! 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"); +pub const layout = @import("layout.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 modal = @import("modal.zig"); +pub const look = @import("look.zig"); +pub const filesystem = @import("fs.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"); -/// Every board-shaped capacity, in one table keyed on a profile rather than on -/// the platform. See src/limits.zig. -const limits = @import("limits.zig"); -const message = @import("message.zig"); +pub const panes = @import("panes.zig"); +pub const builtins = @import("builtins.zig"); +const limits = memory.limits; 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 pdf_enabled = panes.Pdf.enabled; +pub const pdf = panes.Pdf.pdf; +pub const memory = @import("memory.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); -/// 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. +const host_io = @import("host_io.zig"); +pub const Host = host_io.Host; 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, esp32p4 }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); -/// Whether a theme change FADES the anchored chrome palette or replaces it. Ten display frames -/// either way (`animation.transition_steps`), and on every screen but one that is a short legible -/// transition rather than a glitch. -/// -/// The exception is a screen reached through a UART. Each of the ten steps recolors every anchored -/// cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten times -/// over, for a fade nobody can see arrive gradually anyway. Off by default for `esp32p4` and settable -/// either way from the build, because the thing that makes it wrong is the transport rather than the -/// target - see `build.zig`. pub const theme_animation = @import("pardes_config").theme_animation; -/// WHICH BUILD THIS IS, for `--version` and for any bug report that follows it. -/// -/// `version` is `build.zig.zon`'s `.version`, read from the manifest by -/// `build.zig` rather than copied beside it, so there is exactly one place to -/// bump. `commit` is the git revision it was built from, and it is OPTIONAL -/// because a source drop is not a repository: a tarball, a container with no -/// `git`, or any checkout outside version control all yield null, and a -/// frontend must say the version happily without one. -/// -/// Both are strings the build baked in, never questions asked at runtime. A -/// binary that shelled out to `git` would describe whatever tree it was -/// standing in rather than the one it came from — and on the board there is -/// neither a `git` nor a process to run it with. pub const version = @import("pardes_config").version; pub const commit: ?[]const u8 = @import("pardes_config").commit; -/// 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 whose frontend can hand its screen to a detached core — which is -/// narrower than `hosted`, and the gap is a bug this predicate exists to close. -/// -/// macOS is hosted, has a unix socket, and compiles `detached/`; what it does -/// not do is POLL. `takeAttach` is a poll rather than a host method precisely -/// because attaching replaces the core the call is running inside (see -/// `Effect.attach`), and `src/macos.zig` never calls it. Gated on `hosted`, the -/// `Attach` word therefore parsed, queued an effect, stored a request in -/// `attach_buf` — and did nothing at all, for ever, silently. That is the -/// failure this codebase refuses everywhere else, so the word does not exist -/// on a frontend that cannot serve it. -/// -/// The two here are exactly the two `main.zig` accepts `--attach` for, which is -/// the same question asked at the command line instead of in a tag. pub const can_attach = platform == .tty or platform == .gui; -/// 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 != .esp32p4; -/// ...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 {}; +pub 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 PdfRasterPolicy = panes.Pdf.RasterPolicy; pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{ .dpi = 96, @@ -181,37 +65,17 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { .web, .esp32p4 => 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 Pane = panes.Pane; + pub const MAX_PANES = 16; -pub const PDF_PAGE_GAP_PX = pdf_pane.page_gap_px; +pub const PDF_PAGE_GAP_PX = panes.Pdf.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. +const column_weight_unit = layout.column_weight_unit; +const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; -/// Where a reduced-height tagline band sits inside its body-sized grid row, in -/// physical pixels down from the row's top. ONE rule for both pixel hosts: the -/// SDL shell (`src/gui/gui.zig`) and the AppKit shell (`src/macos.zig`, over the -/// C ABI) both call this. It lived in gui.zig, the native shell grew its own -/// copy that only ever centred, and centring is precisely the case -/// `config.gui_topbar_pane_border_px` exists to avoid: two half-bands touching -/// with a strip of window background showing between them, widening as the -/// tagline face shrinks. -/// -/// Row zero and the first pane-tag row face a shared rule instead of centering -/// two independent bands. A Tagbottom band on the final grid row faces the -/// window edge, eliminating the matching unused half-band at the bottom. pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; const border = topbarPaneBorderPixels(cell_h, tagline_h); @@ -222,37 +86,12 @@ pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u return spare / 2; } -/// The rule between the topbar band and the first pane-tag band, clamped to the -/// spare pixels those two bands have between them so a wide compiled value -/// cannot paint over either. pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); } -/// The column a compact tagline band anchors at: the left edge of the pane -/// whose tag row this cell sits on. ONE rule for both pixel hosts, for exactly -/// the reason `taglineBandOffset` is one — the SDL shell reached this through -/// its own copy of the pane walk, and the AppKit shell could not do the walk at -/// all (pane rects are not on its C ABI), so its tag rows advanced on BODY -/// pitch with the smaller glyph merely centred in each body cell. Same session, -/// same percentage, visibly looser tracking in one of the two windows. -/// -/// CELLS, and fractional on purpose: an animating panel's box is fractional, -/// and rounding here would step a sliding pane's tag row a whole body cell at a -/// time while the rest of the pane moved smoothly. -/// -/// `track` is the panel track painting this cell, when one is. It is a -/// parameter rather than something looked up here because the caller has -/// already decided which track owns the cell — the SDL shell from its paint -/// plan, the C ABI wrapper from the frame's track list — and two answers to -/// that question is the drift this function exists to prevent. -/// -/// The last resort is the cell's own column, which puts that one cell back on -/// body pitch. That is deliberate: a stale cell whose pane has closed, or any -/// cell of an attached window, still has to be legible, and a band anchored at -/// a pane that no longer exists is not. -pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_animation.Track) f32 { +pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 { if (row < TOPBAR_H) return 0; if (track) |active| { const box = active.contentBox(); @@ -270,12 +109,6 @@ pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_anim return @floatFromInt(col); } -/// `taglineOriginCol` for a host with no paint plan of its own: the owning -/// track is resolved from the frame's own list. The SDL shell already knows -/// which track is painting a cell and passes it; AppKit reaches the grid -/// through the C ABI and does not, so the lookup belongs here rather than in -/// the wrapper — a second answer to "which track owns this cell" is exactly -/// the drift `taglineOriginCol` was moved into the core to stop. pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { for (p.surface.panelTracks()) |track| if (track.contentBox().contains(col, row)) @@ -284,7 +117,7 @@ pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { } test "paint order is moving, then opening, then closing tombstones on top" { - const Track = panel_animation.Track; + const Track = layout.Track; // Deliberately interleaved on the way in: the phases are what order the // output, not the slot they happened to occupy. const live = [_]?Track{ @@ -298,7 +131,7 @@ test "paint order is moving, then opening, then closing tombstones on top" { .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, }; var out: [8]Track = undefined; - const len = panel_animation.paintOrder(&live, &closing, &out); + const len = layout.paintOrder(&live, &closing, &out); var serials: [8]u32 = undefined; for (out[0..len], 0..) |track, i| serials[i] = track.serial; @@ -307,25 +140,11 @@ test "paint order is moving, then opening, then closing tombstones on top" { // A host's array is fixed-size and the core's is not its business: writing // past it would be a buffer overrun in whichever shell had the smaller one. var tight: [2]Track = undefined; - try std.testing.expectEqual(@as(usize, 2), panel_animation.paintOrder(&live, &closing, &tight)); + try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight)); try std.testing.expectEqual(@as(u32, 12), tight[0].serial); try std.testing.expectEqual(@as(u32, 15), tight[1].serial); } -/// The INVERSE of the two rules above: which grid column a pointer sits in, -/// given where the glyphs actually went. Compacting a tag row without -/// compacting the hit test is a click that lands one word to the right by the -/// end of the row, so these two are one feature and belong in one place. -/// -/// `x` and both widths are in whatever unit the host measures in — physical -/// pixels for SDL, points for AppKit — because only their RATIO is used. -/// -/// The topbar anchors at column zero, a pane tag row at its pane's left edge -/// and is clamped to that pane's last column so a click in the slack at the -/// right of a compacted band stays on the pane it was aimed at, and everything -/// else is the body grid. Deliberately track-blind: the pointer is aimed at -/// what is on screen NOW, and a mid-animation pane is somewhere its own -/// geometry says it is not yet. pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const body = @max(body_w, 1); const tag = @max(tagline_w, 1); @@ -356,7 +175,7 @@ test "a compact tagline anchors at its own pane, and both shells step from the s const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); // Two panes side by side put two tags on ONE row, which is the case a @@ -371,19 +190,9 @@ test "a compact tagline anchors at its own pane, and both shells step from the s // is why row zero is right even in a window with no core to ask. try std.testing.expectEqual(@as(f32, 0), taglineOriginCol(p, 40, 0, null)); - // A row no pane tags falls back to the cell's own column, which is the - // identity that puts that cell back on body pitch rather than sliding it - // somewhere a closed pane used to be. const body_row = left.y + 2; try std.testing.expectEqual(@as(f32, 7), taglineOriginCol(p, 7, body_row, null)); - // THE CROSS-SHELL CONTRACT. gui.zig lays a compact cell out as - // `x_off + col * tag_w` with `x_off = origin * (body_w - tag_w)`; the - // AppKit shell spells the same placement as - // `origin * body_w + (col - origin) * tag_w`. They are the same line of - // algebra and this is the assertion that keeps them one: the two windows - // are supposed to be indistinguishable at the same percentage, and the - // whole bug was one of them quietly using body pitch. const body_w: f32 = 10; const tag_w: f32 = 8; for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { @@ -393,12 +202,6 @@ test "a compact tagline anchors at its own pane, and both shells step from the s try std.testing.expectEqual(sdl, appkit); } - // ...and the POINTER agrees with both. Placing a glyph on a narrower pitch - // while still dividing clicks by the body cell is a hit that drifts one - // column further right for every column along the row — dead centre of the - // last word in a wide tag lands on empty space past its end. Forward and - // inverse live in different files and different languages; this is what - // keeps them inverses. for ([_]u16{ 0, 1, 4, 9 }) |offset| { const col = left.x + offset; if (offset >= left.w) break; @@ -417,42 +220,19 @@ test "a compact tagline anchors at its own pane, and both shells step from the s try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w)); } -/// 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"); -} +test {} 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 @@ -464,24 +244,9 @@ const legacy_terminal_pane_tail = " New Del Filter"; // 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 const test_api = if (@import("builtin").is_test) struct { pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { return p.tagText(arena, pane); } @@ -519,9 +284,6 @@ fn nextPipeEffect(p: *Pardes) ?u32 { 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| { @@ -538,19 +300,12 @@ fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { return if (len) |n| buf[0..n] else null; } -/// Drain the queue through the in-process host — what a shell's pump does with -/// it — and report the Attach among those effects. Going through `perform` is -/// the point: what a frontend acts on is what `takeAttach` hands back AFTER the -/// drain, not the effect value, which dies in the loop that read it. fn drainForAttach(p: *Pardes) ?AttachRequest { while (p.nextEffect()) |effect| p.perform(effect); return p.takeAttach(); } test "Attach asks for a session and tears nothing down" { - // `can_attach` and not `hosted`: 29ac9be compiled both words out of a - // frontend that never polls `takeAttach`, which is macOS — hosted, with a - // unix socket, and still no word to run here. if (comptime !can_attach) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -583,9 +338,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le defer p.deinit(); while (p.nextEffect()) |_| {} - // Whole-word only, like Kill: the effect names the pane that ran it and - // carries nothing else, because the daemon that serves it already knows - // which frontend's keystroke arrived. try std.testing.expect(p.executeBuiltinLine(0, "Detach")); const asked = while (p.nextEffect()) |effect| switch (effect) { .detach => |d| break d, @@ -593,9 +345,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le } else return error.NoDetachAsked; try std.testing.expectEqual(@as(u8, 0), asked.pane); - // ...and this core is a LOCAL shell — a bare `Host{}` fills in no - // `push_detach` — so performing it reports on that pane instead of - // dismissing a session this process is not part of. p.perform(.{ .detach = asked }); const pane = p.panes[0].?; try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]); @@ -607,18 +356,18 @@ test "selection pipe prompt submits exact request and Escape cancels" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("one\ntwo\n"); + const pane = try p.setTestFile("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(pane.tag_edit and pane.prompt == .pipe); 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()); + try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); 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); @@ -630,7 +379,7 @@ test "selection pipe prompt submits exact request and Escape cancels" { 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.expect(!pane.tag_edit and pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, before, pane.file.?.content); try std.testing.expect(nextPipeEffect(p) == null); } @@ -641,9 +390,9 @@ test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" defer p.deinit(); while (p.nextEffect()) |_| {} const long = "a" ** 400; - const pane = try p.hxOpenFileContent(long ++ "\nsecond\n"); + const pane = try p.setTestFile(long ++ "\nsecond\n"); p.settings.wrap = true; - const width = file_pane.wrapWidth(pane, true); + const width = panes.File.wrapWidth(pane, true); try std.testing.expect(width > 4 and long.len > width * 3); // gj holds the column INSIDE the row and lands on the next break; the line @@ -710,9 +459,6 @@ test "the message log keeps what the row forgets, and collapses repeats" { p.setMessage(0, "14:32:09 saved /x.zig"); // same event, later clock p.setMessage(0, "save: AccessDenied"); - // Two entries, not three: the clock does not make a message new. This is - // the case the de-duplication exists for and the one it used to miss, - // because `message.stamp` makes every host message unique by construction. try std.testing.expectEqual(@as(usize, 2), p.messages_len); const first = p.messageLog(0).?; try std.testing.expectEqual(@as(u16, 2), first.repeats); @@ -752,13 +498,12 @@ test "the acme chords act once per selection, not once on the primary" { while (p.nextEffect()) |_| {} // Two selections, each naming a DIFFERENT builtin, so what ran is visible // in the layout rather than in a shell nobody can read from a test. - const pane = try p.hxOpenFileContent("Newcol\nNewcol\n"); - const pl = try p.paneCursorLines(pane); - const ranges = [_]modal.HxRange{ + const pane = try p.setTestFile("Newcol\nNewcol\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 6 }, .{ .anchor = 7, .head = 13 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); try std.testing.expectEqual(@as(u8, 1), pane.nsel); const before = p.ncol; @@ -775,13 +520,12 @@ test "selection pipe replaces all ranges atomically and undo restores them" { 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{ + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); @@ -792,15 +536,17 @@ test "selection pipe replaces all ranges atomically and undo restores them" { try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); - // `AA\n` for a selection that was just `aa`: helix takes a trailing newline - // back off when the input did not have one, which is what keeps a one-line - // `| tr a-z A-Z` from becoming two lines. The empty output for `cc` deletes - // it outright, which is a filter's ordinary right. + var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.scratch.deinit(); + p.scratch = .init(denied.allocator()); const outputs: []const []const u8 = &.{ "AA\n", "" }; p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); try std.testing.expectEqual(@as(u8, 1), pane.nsel); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col); + try std.testing.expect(!denied.has_induced_failure); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content); @@ -829,9 +575,8 @@ test "the four shell behaviours put their output where helix puts it" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("aa bb cc\n"); - const pl = try p.paneCursorLines(pane); - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); + const pane = try p.setTestFile("aa bb cc\n"); + pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); p.update(.{ .key = case.key }); for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); @@ -847,13 +592,12 @@ test "a command with no stdin runs once and every cursor gets that one answer" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); 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{ + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); @@ -861,15 +605,12 @@ test "a command with no stdin runs once and every cursor gets that one answer" { const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; - // ONE invocation for two cursors, with nothing on its stdin — helix's - // `shell_output` cache. Two invocations of `date` could disagree, and ten - // cursors would mean ten forks to produce one answer. try std.testing.expectEqual(@as(usize, 1), request.inputs.len); try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes); p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } }); try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe failure and stale completion never mutate the file" { @@ -877,7 +618,7 @@ test "selection pipe failure and stale completion never mutate the file" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("abc\n"); + const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -887,16 +628,16 @@ test "selection pipe failure and stale completion never mutate the file" { 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); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.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")); + panes.File.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); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); } test "a failed filter opens an errors buffer carrying the command's own words" { @@ -904,7 +645,7 @@ test "a failed filter opens an errors buffer carrying the command's own words" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("abc\n"); + const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -925,13 +666,10 @@ test "a failed filter opens an errors buffer carrying the command's own words" { // The text is untouched — a failed filter is not an edit... try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); // ...and the cursor did not go anywhere, so `|` again edits the same file. try std.testing.expectEqual(@as(usize, 0), p.active); - // ...but the reason is now READABLE, in an +Errors buffer: the command as - // typed, what became of it, and what the shell said. Every one of those - // three used to be dropped on the floor. var found: ?[]const u8 = null; for (p.panes) |slot| { const q = slot orelse continue; @@ -950,7 +688,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - var pane = try p.hxOpenFileContent("old\n"); + var pane = try p.setTestFile("old\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -958,10 +696,10 @@ test "selection pipe rejects a reused pane slot and a superseded request" { 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 + pane = try p.setTestFile("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); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -979,7 +717,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { 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); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe binding is file-normal-only" { @@ -987,15 +725,15 @@ test "selection pipe binding is file-normal-only" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("x"); + const pane = try p.setTestFile("x"); pane.file.?.output = .{ .from = .search }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(!pane.hasPipePrompt()); + try std.testing.expect(pane.prompt != .pipe); pane.file.?.output = null; pane.mode = .insert; p.update(.{ .key = .{ .cp = '|', .text = "|" } }); - try std.testing.expect(!pane.hasPipePrompt()); + try std.testing.expect(pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); } @@ -1004,7 +742,7 @@ test "insert newline adds one indent level after a closing call" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent(" callback({})\n"); + const pane = try p.setTestFile(" callback({})\n"); pane.mode = .insert; pane.cur_col = 16; @@ -1027,9 +765,6 @@ test "startup config runs builtin lines in order and isolates bad lines" { }); 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().*); @@ -1133,7 +868,7 @@ test "runtime theme changes animate chrome and retarget without a jump" { 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); + for (0..layout.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. @@ -1224,10 +959,6 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { 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; @@ -1297,12 +1028,12 @@ test "Filter is ordered after Del and toggles only its terminal pane" { defer p.deinit(); while (p.nextEffect()) |_| {} const first = p.panes[0].?; - const palette_before = first.vt.colorForXterm(.{ .palette = 1 }).?; + const palette_before = first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; try testing.expect(first.tty_filter); try testing.expect(p.executeBuiltinLine(0, "Filter")); try testing.expect(!first.tty_filter); - try testing.expect(palette_before.eql(first.vt.colorForXterm(.{ .palette = 1 }).?)); + try testing.expect(palette_before.eql(first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); const second_id = p.freeSlot().?; const second = try p.newShell(second_id, ""); @@ -1312,7 +1043,7 @@ test "Filter is ordered after Del and toggles only its terminal pane" { try testing.expect(!first.tty_filter); const doc_id = p.freeSlot().?; - const doc = try image_pane.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); + const doc = try panes.Image.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); try testing.expect(!doc.tty_filter); try testing.expect(p.executeBuiltinLine(doc_id, "Filter")); try testing.expect(!doc.tty_filter); @@ -1333,9 +1064,9 @@ test "an untouched tagline ends where its layout column's widest one does" { // 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); + const f = layout.findPane(p, p.active).?; + layout.insert(p, f.col, f.idx + 1, below_id); + layout.splitBelow(p, p.active, below); p.setCwd(below_id, "/a/deep/dir/whose/name/eats/the/right/pad/the/end/of/its/own/tagline"); p.sync(); while (p.nextEffect()) |_| {} @@ -1344,19 +1075,12 @@ test "an untouched tagline ends where its layout column's widest one does" { 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; @@ -1366,17 +1090,10 @@ test "an untouched tagline ends where its layout column's widest one does" { 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); @@ -1400,9 +1117,6 @@ test "legacy default tag tails upgrade while custom tails remain owned" { 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), }); @@ -1425,9 +1139,6 @@ test "legacy default tag tails upgrade while custom tails remain owned" { 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, .{ @@ -1463,7 +1174,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { // 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 = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(file), legacy_file_pane_tail, @@ -1490,10 +1201,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { 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", &.{}); + const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, @@ -1513,18 +1221,18 @@ test "Joincol folds the active column into its right neighbor, keeping its panes const right = p.freeSlot().?; _ = try p.newShell(right, ""); - try std.testing.expect(p.layoutSplitColumn(0, right, false)); + try std.testing.expect(layout.splitColumn(p, 0, right, false)); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current - p.joinCol(); + layout.joinCol(p); 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; + const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; + const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); - p.joinCol(); // no right neighbor left: inert + layout.joinCol(p); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } @@ -1558,37 +1266,32 @@ test "an unsaved file marker sits between its path and builtins until Save" { 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 pane = try p.setTestFile("before\n"); const clean = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, clean, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); const dirty = try p.tagText(p.scratch.allocator(), pane); - const marker_at = std.mem.indexOf(u8, dirty, "/hxcase.txt *") orelse return error.MissingDirtyMarker; + const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); - // DRAINED FIRST, and the drain is the point rather than ceremony: the - // marker now clears when the write LANDS, not when the effect is queued. - // This test used to pass without it, which is exactly what was wrong — a - // save the host could not do cleared the marker anyway. No frame is - // affected, because `pump` drains before it renders. while (p.nextEffect()) |effect| p.perform(effect); const saved = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, saved, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); - file_pane.changed(p, 0, "external\n"); + panes.File.changed(p, 0, "external\n"); const reloaded = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, reloaded, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); const output = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, output, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } test "unknown Exec from an image writes to a terminal in the image directory" { @@ -1619,14 +1322,11 @@ test "unknown Exec from an image writes to a terminal in the image directory" { 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 }; + const vtable: Host.VTable = .{ .tty_taken = answer }; fn install(f: *FakeTtyQuery, p: *Pardes) void { p.host = .{ .ctx = f, .vtable = &vtable }; @@ -1675,9 +1375,6 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at 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) { @@ -1695,24 +1392,25 @@ 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].?); + try p.deinitPane(p.panes[0].?); p.panes[0] = null; const source = "\x00embedded image bytes\xff"; const raw = try p.image_gpa.dupe(u8, source); - const pane = image_pane.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { + const pane = panes.Image.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { p.image_gpa.free(raw); return err; }; - pane.image.?.petscii = true; + pane.image.?.glyph_art = true; pane.image.?.pmode = .terminal; pane.image.?.ascii = false; try std.testing.expect(pane.appendTag(" Keep Del")); pane.tag_init = true; try p.dumpState(); - const first = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); - defer dump.free(gpa, first); + var first_dump = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); + defer first_dump.deinit(); + const first = first_dump.value; try std.testing.expect(first.panes[0].image.?.petscii); try std.testing.expectEqual(dump.ImagePalette.terminal, first.panes[0].image.?.palette); try std.testing.expect(!first.panes[0].image.?.ascii); @@ -1721,7 +1419,7 @@ test "image dump restores source bytes renderer choices and exact custom 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.expect(restored_pane.image.?.glyph_art); try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag_init); @@ -1729,8 +1427,9 @@ test "image dump restores source bytes renderer choices and exact custom tail" { 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); + var parsed = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); + defer parsed.deinit(); + const redump = parsed.value; const encoded = redump.panes[0].image.?.bytes_b64; const decoded = try dump.decodeBytes(gpa, encoded); defer gpa.free(decoded); @@ -1742,9 +1441,6 @@ test "image dump restores source bytes renderer choices and exact custom 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(); @@ -1795,16 +1491,8 @@ test "a Look on a directory does not type ls into an occupied terminal" { 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 — - // and in the spelling `resolve` HANDS BACK, which is the realpath. `/tmp` - // is itself on Linux and a symlink to `/private/tmp` on Darwin, and the - // `.dir` arm matches a pane by `cwdSlice()` against that realpath, on the - // documented invariant that pane paths are canonical (see `lookAt`). A - // literal "/tmp" therefore matched nothing on a Mac and forked a second - // terminal for a directory that already had one. var realbuf: [4096]u8 = undefined; - const tmp = look.resolve("/tmp", "/", &realbuf).dir; + const tmp = look.resolve(null, "/tmp", "/", &realbuf).dir; p.setCwd(0, tmp); var host: FakeTtyQuery = .{}; host.install(p); @@ -1885,10 +1573,6 @@ test "the host is asked about a tty only where a command line is about to go" { 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 } }); @@ -1902,10 +1586,6 @@ test "the host is asked about a tty only where a command line is about to go" { 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"); @@ -1920,14 +1600,14 @@ test "New opens an empty scratch below the caller, inheriting its directory" { 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; + const source_col = layout.findPane(p, source).?.col; try std.testing.expect(p.executeBuiltinLine(source, "New")); // no shell IO: the scratch is created in-core, focused, below the caller while (p.nextEffect()) |_| {} const id = p.active; try std.testing.expect(id != source); - try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col); + try std.testing.expectEqual(source_col, layout.findPane(p, id).?.col); const np = p.panes[id].?; try std.testing.expect(np.file.?.output != null); // an output buffer, empty try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len); @@ -1935,6 +1615,203 @@ test "New opens an empty scratch below the caller, inheriting its directory" { try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np)); p.setCwd(source, "/tmp/pardes-moved"); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); + try p.removePane(source); + p.sync(); + try std.testing.expect(np.cwd == .owned); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); + p.sync(); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); +} + +test "owned cwd preserves long paths aliases and failed updates" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = p.panes[0].?; + var path: [3072]u8 = @splat('d'); + path[0] = '/'; + for (1..12) |i| path[i * 256] = '/'; + p.setCwd(0, &path); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + try std.testing.expect(pane.cwdSlice().ptr != &path); + const original = pane.cwdSlice(); + try pane.setOwnedCwd(original); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + const effects = p.effects_len; + const free = p.freeSlot().?; + try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1])); + try std.testing.expect(p.panes[free] == null); + p.saveTo(0, "file.txt"); + try std.testing.expectEqual(effects, p.effects_len); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); + + const current = pane.cwdSlice(); + allocator.fail_index = allocator.alloc_index; + p.setCwd(0, "/replacement"); + try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); + allocator.fail_index = std.math.maxInt(usize); + p.setCwd(0, "/replacement"); + try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); + const allocations = allocator.allocations; + p.setCwd(0, "/replacement"); + try std.testing.expectEqual(allocations, allocator.allocations); + pane.clearCwd(); + try std.testing.expect(pane.cwd == .none); + try std.testing.expectEqualStrings("", pane.cwdSlice()); +} + +test "owned cwd close copies are transactional across inherited chains" { + for (0..2) |failed_copy| { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.presentation.enabled = false; + const parent = try p.setTestFile("body\n"); + p.gpa.free(parent.file.?.path); + parent.file.?.path = try p.gpa.dupe(u8, "/work/project/source.zig"); + p.newScratchBelow(0); + const first_id = p.active; + const first = p.panes[first_id].?; + p.newScratchBelow(0); + const second = p.panes[p.active].?; + p.newScratchBelow(first_id); + const grandchild = p.panes[p.active].?; + p.sync(); + while (p.nextEffect()) |_| {} + p.parkPendingWrite(0, "queued"); + const pending = p.pending_write[0].?.bytes; + const columns = p.col_panes; + const counts = p.col_n; + const active = p.active; + const live_bytes = allocator.allocated_bytes - allocator.freed_bytes; + allocator.fail_index = allocator.alloc_index + failed_copy; + try std.testing.expectError(error.OutOfMemory, p.removePane(0)); + try std.testing.expectEqual(parent, p.panes[0].?); + try std.testing.expectEqual(parent, first.cwd.inherited); + try std.testing.expectEqual(parent, second.cwd.inherited); + try std.testing.expectEqual(first, grandchild.cwd.inherited); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(active, p.active); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expectEqual(pending.ptr, p.pending_write[0].?.bytes.ptr); + try std.testing.expectEqual(live_bytes, allocator.allocated_bytes - allocator.freed_bytes); + + allocator.fail_index = std.math.maxInt(usize); + try p.removePane(0); + try std.testing.expect(p.panes[0] == null); + try std.testing.expect(first.cwd == .owned and second.cwd == .owned); + try std.testing.expectEqual(first, grandchild.cwd.inherited); + try std.testing.expect(first.cwdSlice().ptr != second.cwdSlice().ptr); + try p.removePane(first_id); + try std.testing.expect(grandchild.cwd == .owned); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(second)); + try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(grandchild)); + } +} + +test "owned cwd column close allocates every copy before removing any pane" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + p.setCwd(0, "/first"); + p.setCwd(1, "/second"); + const first_child = try p.newDocPane(3); + const second_child = try p.newDocPane(4); + first_child.cwd = .{ .inherited = p.panes[0].? }; + second_child.cwd = .{ .inherited = p.panes[1].? }; + while (p.nextEffect()) |_| {} + const before = p.panes; + const columns = p.col_panes; + const counts = p.col_n; + allocator.fail_index = allocator.alloc_index + 1; + try std.testing.expectError(error.OutOfMemory, p.removeColumn(0)); + for (before, p.panes) |old, current| try std.testing.expectEqual(old, current); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expect(first_child.cwd == .inherited and second_child.cwd == .inherited); + allocator.fail_index = std.math.maxInt(usize); + try p.removeColumn(0); + try std.testing.expect(p.panes[0] == null and p.panes[1] == null); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/first", first_child.cwdSlice()); + try std.testing.expectEqualStrings("/second", second_child.cwdSlice()); +} + +test "owned cwd EOF failure retains the pane and Del retries its close" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.presentation.enabled = false; + p.setCwd(0, "/exited"); + const parent = p.panes[0].?; + p.newScratchBelow(0); + const child = p.panes[p.active].?; + p.sync(); + while (p.nextEffect()) |_| {} + allocator.fail_index = allocator.alloc_index; + p.update(.{ .eof = .{ .pane = 0 } }); + try std.testing.expectEqual(parent, p.panes[0].?); + try std.testing.expectEqual(parent, child.cwd.inherited); + try std.testing.expectEqual(Pane.Mode.normal, parent.mode); + try std.testing.expect(std.mem.indexOf(u8, parent.msg[0..parent.msg_len], "Del retries close") != null); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(p.executeBuiltinLine(0, "Del")); + try std.testing.expect(p.panes[0] == null); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/exited", child.cwdSlice()); +} + +test "owned cwd restore either copies the directory or preserves the old core" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.setCwd(0, "/retained/terminal/directory"); + try p.dumpState(); + const original = p.panes[0].?; + var completed = false; + for (0..256) |failure| { + allocator.has_induced_failure = false; + allocator.fail_index = allocator.alloc_index + failure; + const replacement = p.restore(p.dump_out.?) catch { + try std.testing.expect(allocator.has_induced_failure); + try std.testing.expectEqual(original, p.panes[0].?); + try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); + continue; + }; + defer replacement.deinit(); + try std.testing.expectEqualStrings("/retained/terminal/directory", replacement.panes[0].?.cwdSlice()); + try std.testing.expectEqual(original, p.panes[0].?); + if (!allocator.has_induced_failure) { + completed = true; + break; + } + } + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(completed); +} + +test "owned cwd Save promotion releases the former directory" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + p.newScratchBelow(0); + const id = p.active; + const pane = p.panes[id].?; + try pane.setOwnedCwd("/old/directory"); + p.saveTo(id, "saved.txt"); + try std.testing.expect(pane.cwd == .none); + try std.testing.expect(pane.file.?.output == null); + try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); + try std.testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); } test "Save on a scratch asks for a path in its inherited dir and makes it a file" { @@ -1952,7 +1829,7 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file // 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(np.prompt == .save); try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); // typing the filename and submitting converts it into an ordinary file @@ -1980,7 +1857,7 @@ test "Save on a terminal writes its plaintext scrollback and stays a terminal" { while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(0, "Save")); - try std.testing.expect(pane.hasSavePrompt()); + try std.testing.expect(pane.prompt == .save); try std.testing.expect(pane.appendTag("log.txt")); p.submitSave(0); @@ -2010,7 +1887,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" p.sync(); // the frame boundary that gives the new pane its geometry while (p.nextEffect()) |_| {} - const help: output_pane.Origin = .{ .cmd = .Help }; + const help: panes.Output.Origin = .{ .cmd = .Help }; const id = blk: { for (p.panes, 0..) |slot, i| { const pane = slot orelse continue; @@ -2029,7 +1906,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" // 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.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); p.submitSave(id); @@ -2059,12 +1936,9 @@ test "Save takes the path as an argument, relative to the pane's own directory" 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.expect(pane.prompt != .save); try std.testing.expectEqualStrings( "/tmp/pardes-arg-save/session.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, @@ -2087,20 +1961,13 @@ test "Save takes the path as an argument, relative to the pane's own directory" ); 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; + p.panes[0].?.clearCwd(); try std.testing.expect(p.executeBuiltinLine(0, "Save nowhere.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); } test "a save the host could not do leaves the pane dirty" { const gpa = std.testing.allocator; - // A host that refuses every write: a read-only file, a directory that was - // removed under the pane, a full disk. The core cannot tell those apart and - // does not need to — the host says why on the message row, and this is the - // other half, which is that the pane must NOT come clean. const Refusing = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void { const core: *Pardes = @ptrCast(@alignCast(ctx.?)); @@ -2110,17 +1977,13 @@ test "a save the host could not do leaves the pane dirty" { 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")); + const pane = try p.setTestFile("before\n"); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); - p.host = .{ .ctx = p, .vtable = &.{ .push_write_file = Refusing.writeFile } }; + p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.writeFile } }; try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); - // STILL DIRTY. Until this, `saveFile` marked the pane saved the moment it - // QUEUED the effect, so the tag's ` *` cleared on a save that never - // happened — and `Del` makes no dirty check, so the next click threw the - // edits away with the screen saying they were safe. try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); // ...and the same save against a host that CAN write does come clean, so @@ -2129,7 +1992,7 @@ test "a save the host could not do leaves the pane dirty" { try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); - try std.testing.expectEqualStrings("after\n", p.fallback.get("/hxcase.txt").?); + try std.testing.expectEqualStrings("after\n", p.fallback.get("/test.txt").?); } test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { @@ -2137,15 +2000,15 @@ test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { 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")); + const pane = try p.setTestFile("before\n"); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-copy/other.txt")); // a copy, never a rename: the pane keeps its file, and that file is still // unsaved, so the marker stays where it was - try std.testing.expectEqualStrings("/hxcase.txt", pane.file.?.path); + try std.testing.expectEqualStrings("/test.txt", pane.file.?.path); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); try std.testing.expectEqualStrings( "/tmp/pardes-copy/other.txt", @@ -2158,7 +2021,7 @@ test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { // 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(p.executeBuiltinLine(0, "Save /test.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); } @@ -2173,7 +2036,7 @@ test "saves armed in one batch stay with their own panes" { 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 = try p.setTestFile("file text\n"); const file_id = p.paneIdOf(file) orelse return error.MissingFilePane; try std.testing.expect(shell.isTerminal()); try std.testing.expect(tty_id != file_id); @@ -2227,7 +2090,7 @@ test "Save reaches every tagline with text behind it and no other" { defer p.deinit(); while (p.nextEffect()) |_| {} - const out = try p.hxOpenFileContent("build.zig:1:1 pub fn main\n"); + const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); @@ -2257,10 +2120,10 @@ test "Save reaches every tagline with text behind it and no other" { // 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", &.{}); + const img = try panes.Image.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()); + try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); } @@ -2278,11 +2141,8 @@ test "a save path that names no file is refused before anything is rewritten" { 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()); + try std.testing.expect(scratch.prompt == .save); p.submitSave(id); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); @@ -2307,9 +2167,9 @@ test "a host with no methods at all is a complete in-process pardes" { // 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; + const before = p.panes[0].?.terminal.?.vt.screens.active.cursor.y; p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); - try std.testing.expectEqual(before, p.panes[0].?.vt.screens.active.cursor.y); + try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one p.yank = try gpa.dupe(u8, "copied"); @@ -2317,7 +2177,7 @@ test "a host with no methods at all is a complete in-process pardes" { 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"); + const doc = try p.setTestFile("body\n"); var doc_id: u8 = 0; for (p.panes, 0..) |slot, i| if (slot == doc) { doc_id = @intCast(i); @@ -2332,69 +2192,6 @@ test "a host with no methods at all is a complete in-process pardes" { 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(); @@ -2472,35 +2269,32 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expectEqualStrings(installed[0].path, request); try std.testing.expect(p.settings.font.pending); try std.testing.expect(p.takeFontRequest() == null); - const fake_track: panel_animation.Track = .{ + const fake_track: layout.Track = .{ .serial = p.panes[p.active].?.serial, .pane = @intCast(p.active), .effect = .slide, .from = .{ .w = 20, .h = 10 }, .to = .{ .x = 10, .w = 20, .h = 10 }, }; - p.panel_tracks[p.active] = fake_track; - p.presented_panel_tracks[p.active] = fake_track; - p.panel_presentation_ready = true; + p.presentation.tracks[p.active] = fake_track; + p.presentation.shown_tracks[p.active] = fake_track; + p.presentation.acknowledged = true; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(!p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); try std.testing.expectEqual(@as(u16, 1375), p.settings.font.effective_size_hundredths); - try std.testing.expectEqual(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.expectEqual(config.Runtime.FontSizeUnit.pixels, p.settings.font.effective_size_unit); + try std.testing.expect(p.presentation.tracks[p.active] == null); + try std.testing.expect(p.presentation.pending); try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels)); - // 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; + p.presentation.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); + try std.testing.expect(p.presentation.tracks[p.active] != null); + try std.testing.expect(!p.presentation.pending); // A name nothing answers to changes neither request nor effective state. try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face")); @@ -2539,7 +2333,7 @@ test "restored sessions animate layout changes after bootstrap" { source.dump_out.?, ); defer restored.deinit(); - try std.testing.expectEqual(panel_animation.Transition.slide, restored.settings.panel_transition); + try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition); try std.testing.expect(!restored.animationActive()); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -2547,11 +2341,11 @@ test "restored sessions animate layout changes after bootstrap" { const initial = try restored.render(frame.allocator()); restored.acknowledgePanelPresentation(initial.panelTracks()); _ = try restored.newShell(1, ""); - try std.testing.expect(restored.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(restored, 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.presentation.tracks[0] != null); + try std.testing.expect(restored.presentation.tracks[1] != null); try std.testing.expect(restored.animationActive()); } @@ -2592,12 +2386,6 @@ test "restored terminals keep monotonic nonzero pane identities" { 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 ""); @@ -2610,18 +2398,6 @@ fn chordName(comptime c: config.Chord) []const u8 { } } -/// 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) { @@ -2644,9 +2420,6 @@ fn shortcuts(comptime b: Builtin) []const u8 { @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 @@ -2661,9 +2434,6 @@ fn shortcuts(comptime b: Builtin) []const u8 { 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); @@ -2720,21 +2490,6 @@ fn leaderKeys(comptime path: []const u8) []const u8 { } } -/// 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 @@ -2751,9 +2506,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { } 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] = .{ @@ -2762,9 +2514,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { .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; @@ -2777,9 +2526,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { 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, ' '); @@ -2792,9 +2538,6 @@ comptime { 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; @@ -2822,36 +2565,12 @@ 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 = limits.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 - -/// 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, @@ -2872,28 +2591,12 @@ pub const Theme = struct { 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); @@ -2908,13 +2611,6 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { } } -/// 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 { @@ -2922,13 +2618,6 @@ comptime { @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; @@ -2936,23 +2625,6 @@ comptime { @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. -/// WHAT THE BOARD BOOTS WITH. An empty buffer is honest and useless: the three words that make -/// this board interesting take an address, and a board's address space is precisely the thing you -/// cannot guess. So the buffer is a tour of it - every address below comes from this repository -/// rather than from memory, which is why they are worth trusting: the two flash figures and the two -/// RAM ones are the linker script's own ORIGINs (`05-zig-p4/build.zig`'s MEMORY block), and the -/// peripheral bases are `DR_REG_*` from ESP-IDF's headers as `05-zig-p4/src/hal` uses them. -/// -/// Each command sits alone on its line because an argument list ends at the last argument - a -/// trailing comment would be `ExtraArgument` - so the notes go above the lines they describe. Run -/// one by putting the cursor on it, `x` to select the line, Tab to execute. -/// -/// EVERY LINE IS SHORT ENOUGH TO RENDER WHOLE, which is asserted rather than eyeballed: see the -/// test below. A tour whose lines wrap is a worse first screen than no tour. const boot_buffer = \\x selects a line, Tab runs it. 0x optional. \\ @@ -2977,12 +2649,6 @@ const boot_buffer = \\Gpio 33 ; -// Three times in this port a line in that buffer has been one or two characters too long for the -// board's 56-column grid, and every time it was found by reading the die's screen rather than by -// reading the source - which is the expensive way to find a string literal's length. The bound is -// the grid minus the line-number gutter minus a column, and the margin below it is deliberate: -// pinning the exact gutter width would make this test a restatement of the renderer instead of a -// statement about the text. test "every line of the board's boot buffer renders whole" { const cols: usize = @import("pardes_config").esp32p4_cols; var it = std.mem.splitScalar(u8, boot_buffer, '\n'); @@ -3022,18 +2688,15 @@ pub const ChromeTheme = struct { 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); + @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; -/// The fade, or its absence, decided at comptime so that `-Dtheme-animation=false` leaves -/// `ChromeTheme.interpolate` unreachable and therefore out of the binary entirely. Every call site -/// below is written against the shared interface and needs no condition of its own. const ChromeAnimation = if (theme_animation) - animation.Transition(ChromeTheme) + layout.Animation.Transition(ChromeTheme) else - animation.Immediate(ChromeTheme); + layout.Animation.Immediate(ChromeTheme); const initial_chrome = ChromeTheme.fromTheme(&themes[0]); // ---- the boundary types ---- @@ -3041,7 +2704,6 @@ const initial_chrome = ChromeTheme.fromTheme(&themes[0]); 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, @@ -3057,9 +2719,6 @@ pub const CellStyle = struct { 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, @@ -3070,9 +2729,6 @@ pub const Cell = struct { 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 @@ -3080,10 +2736,6 @@ pub const Cell = struct { 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; @@ -3092,17 +2744,13 @@ pub const Cell = struct { } }; -/// 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 const max_movement_frames = layout.ascii_max_movement_frames; pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { const from = old.printableAscii() orelse return null; @@ -3123,11 +2771,6 @@ pub const AsciiDiff = struct { 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); @@ -3135,18 +2778,10 @@ pub const AsciiDiff = struct { 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); @@ -3157,10 +2792,6 @@ pub const AsciiDiff = struct { } }; -/// 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, @@ -3176,7 +2807,6 @@ pub const PanelCellDiff = union(enum) { return diff != .unchanged; } }; -// EFFECT_CODE_ASCII_DIFF_END test "cell visual equality ignores dead grapheme tail bytes" { var a: Cell = .{ .default = false }; @@ -3199,9 +2829,6 @@ test "ASCII cell diffs ease long byte walks in both directions" { 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)); @@ -3263,10 +2890,6 @@ test "ASCII diff classification skips stable and non-ASCII glyphs" { 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, @@ -3284,8 +2907,8 @@ pub const ImageCacheKey = if (pdf_enabled) struct { } }; -const PdfFitMode = pdf_pane.FitMode; -const PdfTintMode = pdf_pane.TintMode; +const PdfFitMode = panes.Pdf.FitMode; +const PdfTintMode = panes.Pdf.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. @@ -3302,9 +2925,6 @@ pub const NativePlacement = if (pdf_enabled) struct { 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 @@ -3349,7 +2969,7 @@ test "pixel attachment cache key follows both pane lifetime and rendered revisio 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)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(panes.Pdf.PointerDrag)); } } @@ -3382,23 +3002,14 @@ pub const Surface = struct { 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, + panel_tracks: [MAX_PANES * 2]layout.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 { + pub fn panelTracks(s: *const Surface) []const layout.Track { return s.panel_tracks[0..s.npanel_tracks]; } @@ -3431,34 +3042,12 @@ pub const Surface = struct { 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; - // ASCII FAST PATH. Printable ASCII is one byte, one cell, one column, and the general - // path below reaches that answer through a UTF-8 length, a decode, a freshly - // constructed grapheme iterator, a slice validation and a width lookup - per character. - // That made this function 26% of a keystroke when profiled in the ESP32-P4's - // configuration (40x12, no tree-sitter), which is the largest single item there. - // - // The guard on the NEXT byte is what makes it correct rather than merely fast: an ASCII - // base joins a following combining mark, ZWJ or spacing mark into ONE cluster, and every - // scalar that can do that is non-ASCII. So an ASCII byte followed by another ASCII byte - // (or by nothing) is a complete grapheme cluster on its own. Same condition - // `modal.nextGrapheme` uses, for the same reason. - // - // `\t`, `\r` and the C0 controls are excluded by the range test and keep their existing - // handling below; DEL is excluded too. { const b = text[i]; if (b >= 0x20 and b < 0x7f and (i + 1 == text.len or text[i + 1] < 0x80)) { @@ -3479,10 +3068,6 @@ pub const Surface = struct { 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]; @@ -3498,10 +3083,6 @@ pub const Surface = struct { 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); @@ -3517,18 +3098,7 @@ pub const Surface = struct { 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; @@ -3608,10 +3178,6 @@ test "surface print keeps combining and wide graphemes in their display cells" { } test "the ASCII fast path in surface print paints what the general arm paints" { - // The fast path skips a UTF-8 length, a decode, a grapheme iterator, a slice validation and a - // width lookup, so it can only be judged against those: the reference below is `print` with the - // fast-path block deleted and nothing else changed. Both routes paint into equally sized - // surfaces and every cell plus the returned column must match. const ref = struct { fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; @@ -3703,11 +3269,6 @@ test "the ASCII fast path in surface print paints what the general arm paints" { } }; - // The scalars that extend an ASCII base into ONE cluster - a combining mark, a ZWJ sequence, a - // spacing mark, a variation selector - are exactly what the guard on the next byte exists for. - // Wide glyphs check the two-cell accounting either side of the fast path, and the three invalid - // sequences check that a bad start byte, a truncated tail and a bad continuation each still - // become one U+FFFD per undecodable byte instead of being swallowed. const neighbours = [_][]const u8{ "", "x", "\u{301}", "\u{200d}\u{1f680}", "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}", @@ -3738,7 +3299,7 @@ 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"); + const pane = try p.setTestFile("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z"); pane.mode = .insert; pane.cur_col = 22; // on z, after the CJK grapheme @@ -3760,7 +3321,7 @@ test "insert and normal modes edit complete Unicode graphemes" { p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("aλz", pane.file.?.content); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); + panes.File.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, 'λ'); @@ -3768,7 +3329,7 @@ test "insert and normal modes edit complete Unicode graphemes" { 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")); + panes.File.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, 'λ'); @@ -3777,11 +3338,87 @@ test "insert and normal modes edit complete Unicode graphemes" { try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); } +test "flat text movement and selection replay need no scratch rows" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const row_text = "one (two) λ\r\n"; + const text = try gpa.alloc(u8, 4096 * row_text.len); + defer gpa.free(text); + for (0..4096) |row| @memcpy(text[row * row_text.len ..][0..row_text.len], row_text); + const pane = try p.setTestFile(text); + _ = try panes.File.lineIndex(gpa, &pane.file.?); + var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.scratch.deinit(); + p.scratch = .init(denied.allocator()); + + pane.cur_row = 4094; + p.handleNormal(pane, .{ .cp = 'l' }); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + p.handleNormal(pane, .{ .cp = 'j' }); + try std.testing.expectEqual(@as(i32, 4095), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + p.handleNormal(pane, .{ .cp = 'h' }); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + p.executeNormalAction(pane, .{ .textobject = .{ .char = 'w', .around = false } }); + try std.testing.expectEqual(@as(i32, 2), pane.cur_col); + try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); + + pane.vsel.active = false; + pane.cur_row = 1024; + pane.cur_col = 0; + pane.nsel = 1; + pane.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; + p.handleNormal(pane, .{ .cp = 'l' }); + p.handleNormal(pane, .{ .cp = 'j' }); + try std.testing.expectEqual(@as(u8, 1), pane.nsel); + try std.testing.expectEqual(@as(i32, 1025), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqual(@as(i32, 3073), pane.sels[0].row); + try std.testing.expectEqual(@as(i32, 1), pane.sels[0].col); + try std.testing.expect(!denied.has_induced_failure); + try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity()); + + pane.nsel = 0; + pane.cur_col = 4; + p.executeNormalAction(pane, .match_bracket); + try std.testing.expect(denied.has_induced_failure); + try std.testing.expectEqual(@as(i32, 4), pane.cur_col); + denied.fail_index = std.math.maxInt(usize); + p.executeNormalAction(pane, .match_bracket); + try std.testing.expectEqual(@as(i32, 8), pane.cur_col); +} + +test "flat text replacement preserves a terminal fragment origin and direction" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + p.update(.{ .output = .{ .pane = 0, .bytes = "first\r\nsecond\r\nthird\r\n界a\r\n" } }); + const pane = p.panes[0].?; + pane.mode = .normal; + pane.cur_pinned = true; + pane.cur_row = 3; + pane.cur_col = 0; + pane.vsel = .{ .active = true, .row = 3, .col = 3, .explicit = true }; + p.normalReplaceChar(pane, 'λ'); + try std.testing.expectEqual(@as(i32, 3), pane.ovl.?.row); + try std.testing.expectEqualStrings("λλ", pane.ovl.?.text); + try std.testing.expectEqual(@as(i32, 3), pane.cur_row); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + try std.testing.expectEqual(@as(i32, 3), pane.vsel.row); + try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); + try std.testing.expect(pane.vsel.active and pane.vsel.explicit); + const lines = try p.paneCursorLines(pane); + try std.testing.expectEqualStrings("first", lines[0]); + try std.testing.expectEqualStrings("λλ", lines[3]); + const flat = try p.flatSurface(pane); + try std.testing.expect(std.mem.startsWith(u8, flat, "first\nsecond\nthird\nλλ\n")); +} + 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 pane = try p.setTestFile("a界e\u{301}z\n"); const f = &pane.file.?; p.gpa.free(f.path); f.path = try p.gpa.dupe(u8, "界e\u{301}.txt"); @@ -3822,7 +3459,7 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column 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 pane = try p.setTestFile("\treturn x\n"); const f = &pane.file.?; f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); @memset(f.highlights, @intFromEnum(syntax.Syn.none)); @@ -3848,13 +3485,9 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column 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; + const blank_x = text_x + @as(u16, @intCast(panes.File.displayWidth(line))) + virtual; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = blank_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = blank_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, @intCast(line.len + @as(usize, virtual))), pane.cur_col); @@ -3868,7 +3501,7 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column // 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)); + const adjacent: i32 = @intCast(config.PREFIX_W + panes.File.displayWidth(line)); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null); const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); @@ -3882,11 +3515,11 @@ test "Look hover waits without mutating the pane and input cancels it" { 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"); + const pane = try p.setTestFile("alpha beta gamma\n"); while (p.nextEffect()) |_| {} const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; _ = try p.newShell(other_id, ""); - try std.testing.expect(p.layoutSplitColumn(0, other_id, false)); + try std.testing.expect(layout.splitColumn(p, 0, other_id, false)); p.sync(); while (p.nextEffect()) |_| {} @@ -3941,9 +3574,6 @@ test "Look hover waits without mutating the pane and input cancels it" { 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); @@ -3956,9 +3586,6 @@ test "Look hover waits without mutating the pane and input cancels it" { 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); @@ -4020,7 +3647,7 @@ 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"); + const pane = try p.setTestFile("abcdef\n"); pane.cur_row = 0; pane.cur_col = 5; pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true }; @@ -4044,7 +3671,7 @@ test "plain left click clears explicit modal selection" { 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"); + const pane = try p.setTestFile("alpha beta\n"); p.acknowledgePanelPresentation(&.{}); const rect = p.rects[0]; const x = rect.x + config.GUTTER + config.PREFIX_W + 1; @@ -4052,7 +3679,7 @@ test "selection drag and queued release do not enter panel pending state" { 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); + try std.testing.expect(!p.presentation.pending); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } }); try std.testing.expect(p.drag == .none); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); @@ -4066,10 +3693,6 @@ pub const Mouse = struct { 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, }; @@ -4078,9 +3701,6 @@ pub const Key = struct { 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; @@ -4099,42 +3719,21 @@ pub const Key = struct { 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 { +fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", @@ -4150,18 +3749,13 @@ fn roleBindingName(comptime role: normal_input.Role) []const u8 { }; } -/// 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 = .{ +fn normalInput(key: Key) modal.Normal.Input { + var out: modal.Normal.Input = .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, }; - inline for (std.enums.values(normal_input.Role)) |role| { + inline for (std.enums.values(modal.Normal.Role)) |role| { const name = comptime roleBindingName(role); if (!@hasDecl(config, name)) @compileError("normal input role has no config binding: " ++ name); @@ -4190,51 +3784,19 @@ pub const Event = union(enum) { 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. - /// `failure` is borrowed for this update like `outputs`, and is what the - /// core turns into an `+Errors` pane. Null with `success = false` means - /// nobody ever ran it — a host with no `pull_pipe` at all. + lsp_resp: struct { id: u32, rows: ?[]const u8 }, pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure = null, }, - /// 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. @@ -4245,131 +3807,46 @@ pub const Event = union(enum) { /// 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, + fs_req: filesystem.Req, }; -/// The longest session name `Effect.attach` can carry. A name is ONE path -/// component under the runtime socket directory (detached/server.zig -/// `socketPath`), so `sun_path`'s 108 bytes cap a usable one far below this; -/// 256 is the width `spawn`'s cwd and `open_link` already reserve, and reusing -/// it is why the new arm costs the effect ring nothing — `save_text`'s -/// {pane, serial, Buf(256)} is still the widest thing in the union. pub const attach_name_max = 256; -/// What `takeAttach` hands the shell: the session to reach for (empty means -/// "whichever one is there") and the pane whose message row a failed connect -/// is reported on, the way `Effect.dump_themes` carries the pane that receives -/// the native host's answer. pub const AttachRequest = struct { pane: u8, name: []const u8 }; -/// A pane's pty bytes waiting for room in the effect ring. Core-owned (the -/// `Event.paste` slice they came from is borrowed for one `update` only), and -/// freed the moment `off` reaches the end or the pane goes away. pub const PendingWrite = struct { bytes: []u8, off: usize = 0 }; -/// WHAT `pty/ctl`'s `sig` VERB CAN SEND. Named rather than numeric because the -/// core is freestanding: it has no `SIGINT` to name and a number written here -/// would be one platform's number travelling to a host that may not share it. -/// Five, and deliberately not the whole of signal(7): these are the ones a -/// human at a terminal already has a key or a `kill` for, and every one of -/// them means something to a program on a tty. `sig USR1` at a shell is a -/// message to a daemon, not a terminal operation, and nothing asked for it. pub const PtySignal = enum(u8) { int, term, hup, quit, kill }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. +pub const WatchMode = enum { reconcile, baseline_disk }; +pub const effect_path_cap = 256; + pub const Effect = union(enum) { - spawn: struct { pane: u8, cwd: Buf(256) }, + spawn: struct { pane: u8, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, - /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s - /// `sig INT`, i.e. the ^C a script cannot type because ^C is not a byte - /// the pty would interpret on its own. The only genuinely new capability - /// the `pty/` directory added: `spawn` and `resize_pty` above were already - /// here, so `exec` and `winsize` are those two acquiring a name. signal_pty: struct { pane: u8, sig: PtySignal }, open_link: Buf(256), /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, - /// 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) + save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap) }, 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. + watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile }, theme_file: struct { generation: u32, on: bool }, /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, - /// 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, - /// `Attach [name]` — hand this frontend's screen to a detached core, the - /// one `pardes --detach [name]` left running; an empty name means "the - /// session that is there". `pane` is where a failed connect is reported. - /// - /// CONNECT FIRST, SWAP SECOND is what the shell owes this, and it is the - /// whole point of the word: the session's socket must be open before - /// anything local is torn down, so an attach that fails leaves this - /// instance running with every pane and every undo intact instead of half - /// dead. Which is also why no host method performs it — see `perform`. + fs_reply: filesystem.Reply, attach: struct { pane: u8, name: Buf(attach_name_max) }, - /// `Detach` — this frontend leaves; the session and every other frontend - /// carry on. tmux's detach-client, and deliberately NOT the inverse of - /// `attach`: turning a live LOCAL session into a daemon needs setsid and a - /// fork, or closing the terminal takes the session with it. - /// - /// No name travels because there is nobody to name. The word is typed in a - /// frontend that has no core of its own, reaches the daemon as an ordinary - /// `Event.command`, and the daemon routes the effect back to the frontend - /// whose keystroke caused it. `pane` is only for the report a local shell - /// gets instead — `perform`'s null-method arm. detach: struct { pane: u8 }, quit, @@ -4392,22 +3869,6 @@ pub const Effect = union(enum) { } }; -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, @@ -4421,15 +3882,7 @@ const LookHoverPreview = struct { 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. + sel: ?Pane.Sel, file_word: ?FileWordSpan = null, }; @@ -4444,7 +3897,7 @@ const PdfWordPreview = if (pdf_enabled) struct { row: u16, pane: usize, serial: u32, - probe: pdf_pane.WordProbe, + probe: panes.Pdf.WordProbe, fn deinit(preview: *@This(), gpa: std.mem.Allocator) void { preview.probe.deinit(gpa); @@ -4452,576 +3905,11 @@ const PdfWordPreview = if (pdf_enabled) struct { } } 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, - /// WHICH of helix's shell commands armed `prompt.pipe`. Beside the prompt - /// rather than inside it because `promptAt` reads all three prompt kinds - /// through one prong, and a payload here would have split that. - pipe_how: normal_input.PipeBehavior = .replace, - 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: [limits.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 `limits.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: [limits.wrap_rows]i32 = undefined, - wrap_col: [limits.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, locations }; - 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; - // A RENDERING, not a document: +Grep, +Search, +Lsp and their kin - // have no language of their own, and their rows quote several at - // once. Colour each row by the file its location names instead. - // Buffers with no locations in them (+Help, +Config) match nothing - // and stay plain, so this needs no table of which origins qualify. - // - // `saves` is the line between the two: a New scratch and a real - // file are output-shaped but ARE documents, with one language and - // an edit on every keystroke — they keep `.source`, which is both - // right for them and what keeps a megabyte of scratch off the - // whole-buffer pass below. - if (f.output != null and !output_pane.fileTraits(f.output).saves) return .locations; - 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 @@ -5032,13 +3920,9 @@ const Drag = union(enum) { 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 = .{}, + pdf: panes.Pdf.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, @@ -5048,33 +3932,12 @@ const Drag = union(enum) { } }; -// 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; @@ -5091,10 +3954,6 @@ test "a corner drag's two axes clamp independently" { 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); @@ -5115,9 +3974,6 @@ test "a corner drag's two axes clamp independently" { 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)); @@ -5129,30 +3985,20 @@ test "a corner drag's two axes clamp independently" { 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]]; + const r = p.rects[p.col_panes[c][k]]; return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1; } -/// 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.active = p.col_panes[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; @@ -5175,8 +4021,8 @@ test "a v-handle press at the RIGHT column's seam drags both boundaries" { 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; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); try std.testing.expect(p.drag == .border_v); @@ -5191,8 +4037,8 @@ test "a v-handle press at the RIGHT column's seam drags both boundaries" { // 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); + try std.testing.expect(p.panes[p.col_panes[1][0]].?.vweight != v_right); + try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); } test "a tie row still moves the LEFT column's pane pair only" { @@ -5207,8 +4053,8 @@ test "a tie row still moves the LEFT column's pane pair only" { 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; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } }); const corner = p.drag.border_v.corner orelse return error.NoCorner; @@ -5217,8 +4063,8 @@ test "a tie row still moves the LEFT column's pane pair only" { 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); + try std.testing.expect(p.panes[p.col_panes[0][0]].?.vweight != v_left); + try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a v-handle press at nobody's seam is still a plain edge drag" { @@ -5233,8 +4079,8 @@ test "a v-handle press at nobody's seam is still a plain edge drag" { 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; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } }); try std.testing.expect(p.drag == .border_v); try std.testing.expect(p.drag.border_v.corner == null); @@ -5243,8 +4089,8 @@ test "a v-handle press at nobody's seam is still a plain edge drag" { 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); + try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); + try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a RIGHT-column corner's two axes clamp independently" { @@ -5264,33 +4110,27 @@ test "a RIGHT-column corner's two axes clamp independently" { // 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]]; + const a = p.rects[p.col_panes[1][0]]; + const b = p.rects[p.col_panes[1][1]]; try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } }); } test "a new column takes width only from the column that created it" { if (platform == .web) return; - // 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 std.testing.expect(layout.splitColumn(p, 0, 1, false)); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(0, 2, true)); + try std.testing.expect(layout.splitColumn(p, 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)); + try std.testing.expect(layout.splitColumn(p, 0, 3, false)); p.sync(); try std.testing.expectEqual(left_before.x, p.rects[2].x); @@ -5302,51 +4142,42 @@ test "a new column takes width only from the column that created it" { 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.expect(!layout.splitColumn(narrow, 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 std.testing.expect(layout.splitColumn(sequential, 0, 1, false)); _ = try sequential.newShell(2, ""); - try std.testing.expect(!sequential.layoutSplitColumn(0, 2, false)); + try std.testing.expect(!layout.splitColumn(sequential, 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)); + try std.testing.expect(layout.splitColumn(coarse, 0, 1, false)); coarse.sync(); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[0]); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[1]); - // 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 std.testing.expect(layout.splitColumn(extreme, 0, 1, false)); _ = try extreme.newShell(2, ""); - extreme.layoutInsert(0, 1, 2); + layout.insert(extreme, 0, 1, 2); extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double extreme.col_weight[1] = 1; - extreme.computeGeom(); - const terms_before = extreme.col_terms; + layout.compute(extreme); + const panes_before = extreme.col_panes; const counts_before = extreme.col_n; const weights_before = extreme.col_weight; const source_vweight = extreme.panes[0].?.vweight; const sibling_vweight = extreme.panes[2].?.vweight; - try std.testing.expect(!extreme.layoutSplitColumn(0, 0, false)); - try std.testing.expectEqual(terms_before, extreme.col_terms); + try std.testing.expect(!layout.splitColumn(extreme, 0, 0, false)); + try std.testing.expectEqual(panes_before, extreme.col_panes); try std.testing.expectEqual(counts_before, extreme.col_n); try std.testing.expectEqual(weights_before, extreme.col_weight); try std.testing.expectEqual(source_vweight, extreme.panes[0].?.vweight); @@ -5358,27 +4189,24 @@ test "Newcol refuses an unsplittable restored weight before spawning" { 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)); + try std.testing.expect(layout.splitColumn(p, id - 1, id, false)); } const restored_cap = std.math.maxInt(u64) / 6; for (0..4) |column| p.col_weight[column] = restored_cap; p.col_weight[3] -= 1; for ([_]usize{ 3, 2, 1 }) |id| { const pane = p.panes[id].?; - p.layoutRemove(id); - p.deinitPane(pane); + try p.deinitPane(pane); + layout.removePane(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} try std.testing.expect(p.col_weight[0] > std.math.maxInt(u64) / 2); try std.testing.expect(p.col_weight[0] % 2 == 1); - try std.testing.expect(!p.layoutCanSplitColumn(0)); + try std.testing.expect(!layout.canSplitColumn(p, 0)); const panes_before = p.panes; const serial_before = p.next_serial; try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); @@ -5392,22 +4220,22 @@ test "layout commits publish finite tracks only for changed panes" { defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const untouched = p.rects[1]; p.settings.panel_transition = .slide; _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(0, 2, true)); + try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); // Pane 1 belongs to the unrelated right column. Its exact layout stayed // fixed, so it does not receive a presentation record either. - try std.testing.expect(p.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.expect(p.presentation.tracks[1] == null); + try std.testing.expect(p.presentation.tracks[0] != null); + try std.testing.expect(p.presentation.tracks[2] != null); + try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase); + try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase); try std.testing.expectEqual(untouched, p.rects[1]); try std.testing.expect(p.animationActive()); @@ -5416,7 +4244,7 @@ test "layout commits publish finite tracks only for changed panes" { const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len); - for (0..panel_animation.Transition.slide.frames()) |_| p.update(.tick); + for (0..layout.Transition.slide.frames()) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); @@ -5424,13 +4252,32 @@ test "layout commits publish finite tracks only for changed panes" { 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); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); +} + +test "screen resize keeps its previous storage until allocation succeeds" { + var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .cols = 80, .rows = 24, .tty_only = true }); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + const cells = surface.cells; + p.update(.{ .resize = .{ .cols = 81, .rows = 25 } }); + failing.fail_index = failing.alloc_index; + try std.testing.expectError(error.OutOfMemory, p.render(frame.allocator())); + try std.testing.expectEqual(cells.ptr, surface.cells.ptr); + try std.testing.expectEqual(cells.len, surface.cells.len); + try std.testing.expectEqual(@as(u16, 80), surface.cols); + try std.testing.expectEqual(@as(u16, 24), surface.rows); + failing.fail_index = std.math.maxInt(usize); + const resized = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u16, 81), resized.cols); + try std.testing.expectEqual(@as(u16, 25), resized.rows); + try std.testing.expectEqual(@as(usize, 81 * 25), resized.cells.len); } test "vertical close samples only a canonical baseline containing that pane" { - // 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(); @@ -5441,15 +4288,15 @@ test "vertical close samples only a canonical baseline containing that pane" { p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); p.acknowledgePanelPresentation(opening.panelTracks()); - p.removePane(1); + try p.removePane(1); p.sync(); - try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks); + try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); } // Once the pane itself has reached a canonical acknowledged frame, that @@ -5464,19 +4311,19 @@ test "vertical close samples only a canonical baseline containing that pane" { p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - for (0..panel_animation.Transition.vertical.frames()) |_| p.update(.tick); + for (0..layout.Transition.vertical.frames()) |_| p.update(.tick); _ = frame.reset(.retain_capacity); const canonical = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len); p.acknowledgePanelPresentation(canonical.panelTracks()); const serial = p.panes[1].?.serial; - p.removePane(1); + try 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); + try std.testing.expectEqual(@as(usize, 1), p.presentation.closing_len); + try std.testing.expectEqual(serial, p.presentation.closing[0].serial); } } @@ -5490,7 +4337,7 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); @@ -5501,22 +4348,19 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch // 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; + p.presentation.diffs[changed_index] = .unchanged; _ = frame.reset(.retain_capacity); const refreshed = try p.render(frame.allocator()); try std.testing.expect(refreshed.cell_diffs[changed_index].changed()); p.acknowledgePanelPresentation(refreshed.panelTracks()); - // 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)); + try std.testing.expect(layout.splitColumn(p, 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); + try std.testing.expect(p.presentation.diff_state == .none); + try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); + try std.testing.expect(p.presentation.pending); _ = frame.reset(.retain_capacity); const snapped = try p.render(frame.allocator()); @@ -5533,7 +4377,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const animated = try p.render(frame.allocator()); @@ -5543,7 +4387,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { // 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); + try std.testing.expect(p.presentation.diff_state == .none); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); @@ -5557,15 +4401,12 @@ test "canonical fallback and transition toggle retire unpresented tracks" { 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.applySettingBuiltin(config.Runtime.find("PanelZoom").?, null); + try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); + p.acknowledgePanelPresentation(&.{}); p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; @@ -5577,14 +4418,14 @@ test "canonical fallback and transition toggle retire unpresented tracks" { 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); + p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, same_percent); try std.testing.expect(p.animationActive()); const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73; const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent}); - p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, changed_text); + p.applySettingBuiltin(config.Runtime.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); + try std.testing.expect(p.presentation.pending); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); } test "pointer coordinates follow presented panel geometry" { @@ -5592,7 +4433,7 @@ test "pointer coordinates follow presented panel geometry" { defer p.deinit(); const pane = p.panes[0].?; - const moving: panel_animation.Track = .{ + const moving: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5603,13 +4444,13 @@ test "pointer coordinates follow presented panel geometry" { 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).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?); // Canonical cells covered only by the not-yet-arrived target are inert. - try std.testing.expect(p.presentedPointer(2, 2) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null); // Unrelated screen space remains in the ordinary grid coordinate system. - try std.testing.expectEqual(Pardes.PointerCell{ .col = 100, .row = 10 }, p.presentedPointer(100, 10).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); - p.acknowledgePanelPresentation(&.{panel_animation.Track{ + p.acknowledgePanelPresentation(&.{layout.Track{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -5617,11 +4458,11 @@ test "pointer coordinates follow presented panel geometry" { .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); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 40, 10) == null); // Tracks paint in pane-slot order inside a phase. The later slot is the // top quad and therefore owns an overlap, even though both are moving. - const overlap = [_]panel_animation.Track{ .{ + const overlap = [_]layout.Track{ .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5636,13 +4477,10 @@ test "pointer coordinates follow presented panel geometry" { .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).?); + p.presentation.shown_tracks = @splat(null); + p.presentation.shown_tracks[0] = overlap[0]; + p.presentation.shown_tracks[1] = overlap[1]; + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?); } test "queued pointer input is inert until a changed layout is presented" { @@ -5653,42 +4491,42 @@ test "queued pointer input is inert until a changed layout is presented" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - try std.testing.expect(p.panel_presentation_pending); - try std.testing.expect(p.presentedPointer(10, 4) == null); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); - var tracks: [MAX_PANES]panel_animation.Track = undefined; + var tracks: [MAX_PANES]layout.Track = undefined; var len: usize = 0; - for (p.panel_tracks) |maybe| if (maybe) |track| { + for (p.presentation.tracks) |maybe| if (maybe) |track| { tracks[len] = track; len += 1; }; p.acknowledgePanelPresentation(tracks[0..len]); - try std.testing.expect(!p.panel_presentation_pending); + try std.testing.expect(!p.presentation.pending); // Whether this particular opening sample exposes the chosen cell is the // transition's concern; it is no longer rejected merely as speculative. - try std.testing.expect(p.presentedPointer(99, 4) != null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 99, 4) != null); } test "back-to-back layout commits retarget from the last presented boxes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const initial = try p.render(frame.allocator()); p.acknowledgePanelPresentation(initial.panelTracks()); - const shown_a = Pardes.panelBox(p.rects[0]); + const shown_a = layout.panelBox(p.rects[0]); p.settings.panel_transition = .slide; p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; p.sync(); - const target_b = p.panel_tracks[0] orelse return error.MissingFirstRetarget; + const target_b = p.presentation.tracks[0] orelse return error.MissingFirstRetarget; try std.testing.expect(target_b.from.eql(shown_a)); // No render or acknowledgement of B: the pixels are still A. C must not @@ -5696,21 +4534,21 @@ test "back-to-back layout commits retarget from the last presented boxes" { 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; + const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget; try std.testing.expect(target_c.from.eql(shown_a)); - try std.testing.expect(target_c.to.eql(Pardes.panelBox(p.rects[0]))); - try std.testing.expectEqual(panel_animation.Phase.moving, target_c.phase); + try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0]))); + try std.testing.expectEqual(layout.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); - var submitted: ?panel_animation.Track = null; + var submitted: ?layout.Track = null; for (latest.panelTracks()) |track| if (track.pane == 0) { submitted = track; break; }; const track = submitted orelse return error.MissingSubmittedRetarget; p.acknowledgePanelPresentation(latest.panelTracks()); - try std.testing.expect(p.presented_panel_layout[0].?.box.eql(track.visualBox())); + try std.testing.expect(p.presentation.shown[0].?.box.eql(track.visualBox())); } test "an unpresented opening pane remains in the opening paint phase" { @@ -5723,16 +4561,16 @@ test "an unpresented opening pane remains in the opening paint phase" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 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); + const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack; + try std.testing.expectEqual(layout.Phase.opening, first.phase); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, true)); + try std.testing.expect(layout.splitColumn(p, 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); + const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget; + try std.testing.expectEqual(layout.Phase.opening, retargeted.phase); try std.testing.expect(retargeted.from.eql(first.from)); } @@ -5805,9 +4643,6 @@ test "core composes character motion out of the new grid, not a fade" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - // 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; @@ -5816,7 +4651,7 @@ test "core composes character motion out of the new grid, not a fade" { try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme()); // The last active sample is the untouched canonical grid, with no copy. - surface.panel_tracks[0].frame = panel_animation.Transition.edges.frames() - 1; + surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1; try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); } @@ -5824,24 +4659,24 @@ 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); + const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; + p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.presentation.diffs, .unchanged); for (2..6) |row| { for (4..36) |col| { // Distances on both sides of frame five prove that finished ASCII // bytes are clickable while bytes still walking are not. - p.panel_cell_diffs[row * p.screen_w + col] = .{ .ascii = .{ + p.presentation.diffs[row * p.screen_w + col] = .{ .ascii = .{ .from = ' ', .to = @intCast(' ' + (col - 4) % 12 + 1), } }; } } - p.panel_diff_ready = true; + p.presentation.diff_state = .ready; - for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; - const track: panel_animation.Track = .{ + const track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -5860,22 +4695,22 @@ test "pointer rejects panel content cells which have not materialized" { const col: u16 = @intCast(4 + relative_col); const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { - .dissolve => panel_animation.dissolveRevealed( + .dissolve => layout.dissolveRevealed( pane.serial, rcol, rrow, track.amount(), ), - .ascii => switch (p.panelCellDiff(col, row)) { + .ascii => switch (p.presentation.cellDiff(p.screen_w, p.screen_h, col, row)) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; - const mapped = p.presentedPointer(col, row); + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| - try std.testing.expectEqual(Pardes.PointerCell{ .col = col, .row = row }, point); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point); saw_visible = saw_visible or visible; saw_hidden = saw_hidden or !visible; }; @@ -5888,12 +4723,12 @@ 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 }; + p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.presentation.diffs, .unchanged); + p.presentation.diff_state = .ready; + const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; - for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, @@ -5902,7 +4737,7 @@ test "unchanged content cells remain clickable through data effects" { .from = box, .to = box, }}); - try std.testing.expectEqual(Pardes.PointerCell{ .col = 8, .row = 3 }, p.presentedPointer(8, 3).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?); } } @@ -5912,13 +4747,13 @@ test "stationary Look hover follows only acknowledged panel samples" { defer p.deinit(); const pane = p.panes[0].?; const rect = p.rects[0]; - const target: panel_animation.Box = .{ + const target: layout.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; - var track: panel_animation.Track = .{ + var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5933,7 +4768,7 @@ test "stationary Look hover follows only acknowledged panel samples" { // 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.presentation.tracks[0] = track; p.refreshLookHoverFromRaw(); try std.testing.expectEqual(first, p.look_hover_wait.?); @@ -5963,9 +4798,6 @@ test "stationary Look hover follows only acknowledged panel samples" { 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); @@ -5975,8 +4807,8 @@ 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 = .{ + const target = layout.panelBox(p.rects[0]); + var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -6000,7 +4832,7 @@ test "held drag follows acknowledged panels and balances an invisible release" { // 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; + p.presentation.tracks[0] = track; try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1); p.acknowledgePanelPresentation(&.{track}); @@ -6017,7 +4849,7 @@ test "held drag follows acknowledged panels and balances an invisible release" { .y = target.y + target.h * 0.5, }; p.acknowledgePanelPresentation(&.{track}); - try std.testing.expect(p.presentedPointer(raw_col, raw_row) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) == null); try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1); @@ -6046,12 +4878,9 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 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 } }); @@ -6065,101 +4894,43 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { try std.testing.expectEqual(@as(u16, 418), p.rects[0].w); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, false)); + try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); const left_before = p.rects[0]; const middle_before = p.rects[1]; _ = try p.newShell(3, ""); - try std.testing.expect(p.layoutSplitColumn(2, 3, false)); + try std.testing.expect(layout.splitColumn(p, 2, 3, false)); p.sync(); try std.testing.expectEqual(left_before, p.rects[0]); try std.testing.expectEqual(middle_before, p.rects[1]); } -pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; - -const LayoutSnapshot = struct { - serial: u32, - box: panel_animation.Box, -}; +pub const Rect = layout.Rect; 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, - /// argv NAMED SOMETHING THAT IS NOT THERE — no file, no directory, nothing - /// `look.resolve` could make a target of. The word as the human typed it - /// and the directory they typed it in, and it boots one `+Errors` pane - /// saying so. - /// - /// A launch is not a failure worth refusing. `pardes nosuchfile` used to - /// return `BadArgs` out of `main`, which std prints as `error: BadArgs` - /// with a return trace under it — indistinguishable from a crash for a - /// typo, and it left the human with no editor at all. Outranked by `file` - /// for the same reason `file` outranks `shells`: only one of them can name - /// the boot layout, and they are never both set. - /// - /// `dir` is the LAUNCH DIRECTORY, and the pane needs it for the same - /// reasons every other pane needs one: it is where a `Grep` from that pane - /// walks, where its `Newtty` spawns a shell, and what its `Save` prefills. missing: ?struct { word: []const u8, dir: []const u8 } = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, - /// `--nested`: 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, - /// SERVE THAT SAME TREE OVER 9P2000 on a unix socket (`--fs9`), and under - /// what name. `null` is off; `""` means "derive the socket name" (the - /// session name in a daemon, this pid anywhere else); anything else is the - /// name `--fs9=` gave, which scripts need because they have to - /// predict `$XDG_RUNTIME_DIR/pardes-9p-.sock`. - /// - /// INDEPENDENT of `fs` above: either, both or neither. They are two - /// transports onto one tree (`src/acmefs.zig`) and neither is the other's - /// prerequisite — on Linux the FUSE mount needs `fusermount3` and a - /// kernel with FUSE, and this needs neither, which is the whole reason - /// docs/9p.typ §12.4 calls them complementary rather than competing. - /// - /// Read by `detached/server.zig` and nowhere else so far: a daemon polls - /// its own listener in the one `poll(2)` it already runs, which costs it - /// no thread. The tty and GUI shells would each need their own wake for - /// it, exactly as they need one for `/dev/fuse`, and they take `fs` only. - fs9: ?[]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. + ninep_name: []const u8 = "", + ninep_tcp: ?[]const u8 = null, + ninep_quic: ?[]const u8 = null, + ninep_identity: struct { + socket_path: []const u8 = "", + tcp_address: ?std.Io.net.IpAddress = null, + quic_address: ?std.Io.net.IpAddress = null, + } = .{}, + mounts: []const filesystem.Mount = &.{}, 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, @@ -6167,20 +4938,13 @@ pub const Options = struct { /// 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, }; /// The tag-tail marker each behaviour arms with. One function so the prompt /// that is DRAWN and the command that is PARSED can never disagree. -fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { +fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { return switch (how) { .replace => config.pipe_marker, .ignore => config.pipe_marker_to, @@ -6189,9 +4953,6 @@ fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { }; } -/// One line that was said on a message row. Fixed storage so the log cannot -/// fail: `setMessage` is reached from `reportError`, which is reached from -/// paths that are reporting an allocation failure. pub const LoggedMessage = struct { pub const cap = 256; text: [cap]u8 = undefined, @@ -6215,15 +4976,10 @@ const PendingPipe = struct { command: []u8, cwd: []u8, inputs: []selection_pipe.Input, - ranges: [MAX_SELS]modal.HxRange, + ranges: [Pane.max_selections]modal.Selection, primary: u8, explicit: bool, - how: normal_input.PipeBehavior, - /// How many ranges the selection had. Not always `inputs.len`: `!` and - /// `A-!` send NO stdin and run the command ONCE, so they submit a single - /// empty input and paste that one answer at every range — helix's - /// `shell_output` cache, which is why `date` at ten cursors gives ten - /// identical stamps rather than ten different ones. + how: modal.Normal.PipeBehavior, nranges: u8, fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { @@ -6244,29 +5000,18 @@ const PendingPipe = struct { } }; -/// 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) { +pub const RetiredPanes = struct { + current: [2 * MAX_PANES]?*Pane = @splat(null), + previous: [2 * MAX_PANES]?*Pane = @splat(null), + + fn retire(a: *RetiredPanes, pane: *Pane) void { + for (&a.current) |*slot| if (slot.* == null) { slot.* = pane; return; }; @@ -6274,12 +5019,6 @@ pub const PaneAllocator = struct { // 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 { @@ -6294,193 +5033,59 @@ pub const Pardes = struct { resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), - /// Deferred teardown of dropped panes (see PaneAllocator). - pane_alloc: PaneAllocator = .{}, + reserved_slots: [MAX_PANES]bool = @splat(false), + /// Deferred teardown of dropped panes (see RetiredPanes). + retired_panes: RetiredPanes = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, - /// 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_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), // derived each sync - rects: [MAX_PANES]Rect = undefined, + rects: [MAX_PANES]Rect = @splat(.{}), 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, + presentation: layout.Presentation = .{}, 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. + settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, + tty_filter_palette: panes.Terminal.FilterPalette = .{}, 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 `limits.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(limits.host_path_cap) = .{}, + theme_file_path: config.Runtime.Text(limits.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. - /// - /// Nothing to do with `-Dtheme-animation`: that is decided by the type of - /// `chrome_animation`, so a build without the fade does not carry a flag - /// saying so. 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, @@ -6490,18 +5095,6 @@ pub const Pardes = struct { /// 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, @@ -6514,32 +5107,17 @@ pub const Pardes = struct { 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 = .{}, + fs: filesystem.Namespace = .{}, - /// 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. + // Reject overflow: evicting an older effect would reorder a byte stream. effects: [limits.effect_cap]Effect = undefined, effects_head: usize = 0, effects_len: usize = 0, - /// Pty bytes that did not fit the ring, per pane, kept because refusing a - /// `write` TRUNCATES a byte stream rather than merely delaying it: a 1 MiB - /// paste used to reach a program as its first 256 KiB, silently. `emitWrite` - /// parks the tail here and `nextEffect` refills the ring from it as the host - /// drains, so the stream is delayed and never cut. See `limits.pending_write_cap`. - /// - /// Per pane because two ptys are independent streams: only order WITHIN one - /// matters, so a pane whose tail is waiting never delays another's writes. + // Each pty retains its own overflow tail until earlier effects drain. pending_write: [MAX_PANES]?PendingWrite = @splat(null), /// Total bytes parked above, so the drain path costs one comparison when /// nothing is waiting — which is every frame that is not a large paste. @@ -6550,27 +5128,18 @@ pub const Pardes = struct { 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, + fallback: host_io.Fallback, /// The message-row log: a fixed ring, never allocated, never grown. See /// `logMessage` and the `Messages` builtin. messages: [limits.message_log]LoggedMessage = @splat(.{}), /// Next slot to write. `messages_len` saturates at the ring's size. messages_head: usize = 0, messages_len: usize = 0, - /// 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, @@ -6581,12 +5150,6 @@ pub const Pardes = struct { /// shell consumes it via takeRestore each frame (restore contents stay host-fed) restore_req: ?[]const u8 = null, restore_buf: [1024]u8 = undefined, - /// An Attach builtin wants this frontend's screen handed to a detached - /// core; the shell consumes it via takeAttach from its OUTER loop, beside - /// takeRestore and for the same reason — both END this core, and nothing - /// running inside `pump` may destroy the core it is running in. `name` - /// points into `attach_buf`, which the effect's inline copy is unpacked - /// into: the effect is a value the drain loop owns and dies with it. attach_req: ?AttachRequest = null, attach_buf: [attach_name_max]u8 = undefined, @@ -6597,8 +5160,8 @@ pub const Pardes = struct { /// 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 = .{}, + /// for — see panes.Terminal.RowsCache for the lifetime rule + shell_rows: panes.Terminal.RowsCache = .{}, pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; @@ -6611,6 +5174,11 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .fs = .{ + .socket_path = opts.ninep_identity.socket_path, + .tcp_address = opts.ninep_identity.tcp_address, + .quic_address = opts.ninep_identity.quic_address, + }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -6618,36 +5186,20 @@ pub const Pardes = struct { .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); + for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); + p.opts.mounts = &.{}; + p.opts.ninep_identity = .{}; 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. const opened = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) - break :initial_doc pdf_pane.openPane(p, 0, path, opts.file_line); + break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line); if (look.isImagePath(path)) - break :initial_doc image_pane.create(p, 0, path, &.{}); - break :initial_doc file_pane.open(p, 0, path, opts.file_line); + break :initial_doc panes.Image.create(p, 0, path, &.{}); + break :initial_doc panes.File.open(p, 0, path, opts.file_line); }; - // ...AND IF IT WILL NOT OPEN, SAY SO IN THE WINDOW. `pardes /root` - // is a directory that resolves and cannot be read, so it arrives - // here as a `.file` and used to take the whole launch down with - // `error: PermissionDenied` and a return trace out of `main` — a - // crash, to the human, for asking to read something they are not - // allowed to read. Every environment reason lands in the same - // `+Errors` pane a missing name does, because they are the same - // event to whoever typed it: pardes cannot show you that. - // - // OUT OF MEMORY IS NOT ONE OF THEM and goes back to the caller. - // A core that could not allocate a file cannot allocate the pane - // explaining it, and pretending otherwise turns a clean failure - // into a second one. Every opener does its fallible IO BEFORE it - // claims a pane slot (`look.readFile`, then `newDocPane`), so slot - // 0 is still free here — which is what makes this legal. if (opened) |_| {} else |err| { if (err == error.OutOfMemory) return err; - const why = switch (err) { + const why = switch (@as(anyerror, err)) { error.PermissionDenied => "permission denied", error.FileNotFound => "no file of that name", error.IsDirectory => "that is a directory, and not one that could be read", @@ -6658,82 +5210,35 @@ pub const Pardes = struct { }; const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why }); errdefer gpa.free(content); - _ = try output_pane.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); + _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); } p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (opts.missing) |missing| { - // ARGV NAMED NOTHING. `+Errors` and not the message row, because a - // launch has no pane to put a message row on yet — and because - // this is exactly what acme's `+Errors` is: output from the - // program rather than from a word anybody clicked (output_pane - // `Origin.errors`). Filling the window with it makes the answer - // unmissable, which a one-line message under a shell prompt is - // not. - // - // The word AS TYPED, not a resolved path: there is nothing to - // resolve, and `pardes ~/notes/tdoo.md` wants to see its own typo - // back rather than an absolute path built around it. The pane's - // DIRECTORY is the launch directory all the same — see `missing`: - // "" would put this pane at `/+Errors` and point every word that - // reads a pane's directory at the root of the filesystem. const content = try std.fmt.allocPrint( gpa, "file or directory not found\n\n\t{s}\n", .{missing.word}, ); errdefer gpa.free(content); - _ = try output_pane.open(p, 0, missing.dir, .errors, "", content); + _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (comptime platform == .esp32p4) { - // 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. - // AND IT BOOTS WITH SOMETHING IN IT. An empty buffer is honest and useless: the three - // words that make this board interesting take an address, and a board's address space is - // precisely the thing you cannot guess. - // - // SHORT, because the window is fourteen rows. The first draft opened with four lines of - // prose about there being no operating system, which is true, unhelpful, and cost a - // third of the screen before the first command. One header line earns its place; the - // rest of the screen is addresses. - // - // Each command sits alone on its line because an argument list ends at the last argument - // - a trailing comment would be `ExtraArgument` - so the labels go above. Every address - // is from this repository or from a session that read it on this die: the flash and RAM - // figures are the linker script's own ORIGINs (`05-zig-p4/build.zig`), the peripheral - // bases are the `DR_REG_*` values `05-zig-p4/src/hal` uses, and the two LP addresses at - // the end are named in ESP-IDF's own headers: 0x5011002c is LP_SYSTEM_REG_LP_STORE0, a - // general-purpose retention register that holds what you put in it, and 0x501101a4 is - // LP_SYSTEM_REG_RNG_DATA, the hardware random generator. Between them they demonstrate - // the whole point of a volatile read: one address gives back what was written and the - // other never gives the same answer twice. Both verified on this die. const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); - _ = try output_pane.open(p, 0, "", .{ .cmd = .New }, "", content); + _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content); p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[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; + p.col_panes[0][0] = 0; } else if (opts.shells >= 3) { // classic layout: two columns, the left one split in two. _ = try p.newShell(0, ""); @@ -6742,23 +5247,23 @@ pub const Pardes = struct { 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_panes[0][0] = 0; + p.col_panes[0][1] = 1; p.col_n[1] = 1; - p.col_terms[1][0] = 2; + p.col_panes[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.col_panes[0][0] = 0; } p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); - p.panel_animation_enabled = true; + p.presentation.enabled = true; // A config init that opens a file with `Look …` armed the pulse before // anyone touched anything. Nobody asked for that, so boot is silent. _ = p.takeHaptic(); @@ -6772,11 +5277,11 @@ pub const Pardes = struct { p.teardownPane(pane); slot.* = null; }; - for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; - for (&p.pane_alloc.fresh) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.current) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; @@ -6793,8 +5298,7 @@ pub const Pardes = struct { 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); + p.presentation.deinit(gpa); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } @@ -6830,115 +5334,103 @@ pub const Pardes = struct { 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); + fn detachCwds(p: *Pardes, parents: []const *Pane) !void { + var copies: [MAX_PANES]?[]u8 = @splat(null); + errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const source = switch (pane.cwd) { + .inherited => |parent| parent, + else => continue, + }; + for (parents) |parent| if (source == parent) { + copies[id] = try p.gpa.dupe(u8, paneDir(source)); + break; + }; + } + for (copies, 0..) |copy, id| if (copy) |bytes| { + p.panes[id].?.cwd = .{ .owned = bytes }; + }; + } + + // The slot closes now; allocation teardown waits one frame. Caller clears panes[id]. + pub fn deinitPane(p: *Pardes, pane: *Pane) !void { + try p.detachCwds(&.{pane}); + p.retirePane(pane); + } + + fn retirePane(p: *Pardes, pane: *Pane) void { + const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf(); for (p.panes, 0..) |slot, id| if (slot == pane) { if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); - // 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); // ...and so do bytes still queued for the pty it no longer has. p.dropPendingWrite(id); }; if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); - p.pane_alloc.doom(pane); + p.retired_panes.retire(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. + // Retired panes keep their payloads until the following frame or core teardown. fn teardownPane(p: *Pardes, pane: *Pane) void { if (p.lookHoverPane()) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) p.cancelLookHover(); } p.shell_rows.dropPane(pane); - term_pane.deinitPendingCommand(pane); + panes.Terminal.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 (comptime pdf_enabled) if (pane.pdf) |*pv| panes.Pdf.deinitPane(p, pane, pv); + if (pane.file) |*file| panes.File.deinit(p, pane, file); if (pane.ovl) |o| p.gpa.free(o.text); for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); - term_pane.deinitEmulator(pane, p.gpa); + panes.Terminal.deinitEmulator(pane, p.gpa); + pane.clearCwd(); 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| { + for (&p.retired_panes.previous) |*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 => {}, - }; - } + p.retired_panes.previous = p.retired_panes.current; + p.retired_panes.current = @splat(null); } - /// 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 { + pub fn installPane(p: *Pardes, id: usize, pane: *Pane) void { std.debug.assert(p.panes[id] == null); p.next_serial += 1; pane.serial = p.next_serial; + p.rects[id] = .{}; p.panes[id] = pane; } - /// 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); + if (cwd.len > effect_path_cap) return error.PathTooLong; + const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; } - /// 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); + const pane = try panes.Terminal.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); + const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); np.cwd = .{ .inherited = src }; np.cur_pinned = true; return np; @@ -6947,25 +5439,25 @@ pub const Pardes = struct { /// 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 sf = layout.findPane(p, 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); + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, from_id, np); p.active = free; } /// Newcol: a scratch in a fresh column beside the calling pane. pub fn newScratchColumn(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; - if (!p.layoutCanSplitColumn(from_id)) return; + if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; - std.debug.assert(p.layoutSplitColumn(from_id, free, false)); + std.debug.assert(layout.splitColumn(p, from_id, free, false)); p.active = free; } pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { - if (slot == null) break i; + if (slot == null and !p.reserved_slots[i]) break i; } else null; } @@ -6976,14 +5468,6 @@ pub const Pardes = struct { } 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; @@ -7001,25 +5485,12 @@ pub const Pardes = struct { 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. - /// - /// The ring refuses when full rather than evicting, which is right for every - /// other effect and WRONG for a byte stream: the tail of a large paste was - /// dropped where the program needed it whole (and, under bracketed paste, the - /// closing marker went with it, leaving the program in paste mode). What does - /// not fit parks in `pending_write` and `nextEffect` feeds it back as the host - /// drains, so this never truncates while `pending_write_cap` has room. pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; // Anything already parked for this pane owns the stream's position, so @@ -7034,9 +5505,6 @@ pub const Pardes = struct { if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]); } - /// Take ownership of bytes the ring had no room for. A failed allocation or - /// an exhausted cap degrades to the old behaviour — dropping the tail — because - /// the alternative on a full heap is refusing to run at all. fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void { if (comptime limits.pending_write_cap == 0) return; if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return; @@ -7059,62 +5527,32 @@ pub const Pardes = struct { /// 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 (std.mem.eql(u8, cur, cwd)) return; + pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); 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 (id < MAX_PANES) panes.Terminal.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; @@ -7124,15 +5562,11 @@ pub const Pardes = struct { 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, + unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.effective_name.set(effective_name)) return false; @@ -7147,7 +5581,7 @@ pub const Pardes = struct { p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, - unit: FontSizeUnit, + unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.pending or !p.font_request_taken) return false; @@ -7160,9 +5594,6 @@ pub const Pardes = struct { 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; } @@ -7180,9 +5611,6 @@ pub const Pardes = struct { 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"); @@ -7244,9 +5672,6 @@ pub const Pardes = struct { 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) { @@ -7277,7 +5702,6 @@ pub const Pardes = struct { 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); @@ -7285,37 +5709,61 @@ pub const Pardes = struct { 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. - /// A message row that is NOT worth remembering: unsolicited progress from a - /// language server, which arrives several times a second for the whole of a - /// large index. - /// - /// `rust-analyzer: Indexing 47% core` is a different string every tick by - /// construction, so no de-duplication can collapse it, and at the client's - /// throttle of one per 150ms per server it takes about NINETEEN SECONDS to - /// push every save, error and reload out of a 128-entry ring. A log that - /// one indexing run empties is not a log. Progress belongs on the row, - /// where it is read as it happens and then replaced; the log is for things - /// that were said once. + pub const Message = struct { + const libc = std.c; + const Tm = extern struct { + sec: c_int, + min: c_int, + hour: c_int, + mday: c_int, + mon: c_int, + year: c_int, + wday: c_int, + yday: c_int, + isdst: c_int, + gmtoff: c_long, + zone: ?[*:0]const u8, + }; + extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; + + pub fn body(text: []const u8) []const u8 { + if (text.len < 10) return text; + if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; + for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { + if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; + } + return text[10..]; + } + + pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { + var clock: [8]u8 = "--:--:--".*; + const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; + if (!notime) { + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.REALTIME, &ts); + const secs: libc.time_t = ts.sec; + var tm: Tm = undefined; + if (localtime_r(&secs, &tm) != null) { + _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ + @as(u32, @intCast(tm.hour)), + @as(u32, @intCast(tm.min)), + @as(u32, @intCast(tm.sec)), + }) catch {}; + } + } + const head = std.fmt.bufPrint(buf, "{s} {s} ", .{ &clock, verb }) catch return buf[0..0]; + const room = buf.len - head.len; + const tail = if (subject.len <= room) subject else subject[subject.len - room ..]; + @memcpy(buf[head.len..][0..tail.len], tail); + return buf[0 .. head.len + tail.len]; + } + }; + pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void { p.showMessage(id, text); } pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { - // LOGGED FIRST, and logged even when the pane is gone. A message row - // is cleared by the next keystroke (see `clearMessages`), so anything - // said while the user was looking elsewhere — a save that failed, a - // watcher's reload, a builtin's complaint — used to be unrecoverable - // the instant it appeared. `Messages` reads this back. p.logMessage(id, text); p.showMessage(id, text); } @@ -7327,23 +5775,14 @@ pub const Pardes = struct { @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } - /// Append to the ring, oldest overwritten. No allocation and no failure: - /// this sits under every `reportError` in the program, including the ones - /// raised because an allocation just failed. fn logMessage(p: *Pardes, id: usize, text: []const u8) void { if (text.len == 0) return; const pane: u8 = if (id < MAX_PANES) @intCast(id) else 0xff; - // What is STORED is truncated to the slot, so what is COMPARED must be - // too: comparing a 300-byte message against its own 256-byte record - // never matched, and two identical long messages each got their own - // row. And the comparison is on `message.body` — the row without its - // clock — because a stamp makes every host message unique by - // construction, which defeated this entirely for `saved`/`reloaded`. const kept = text[0..@min(text.len, LoggedMessage.cap)]; if (p.messages_len > 0) { const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log]; if (last.pane == pane and - std.mem.eql(u8, message.body(last.slice()), message.body(kept))) + std.mem.eql(u8, Message.body(last.slice()), Message.body(kept))) { // The NEWEST wording wins, so the row carries the latest clock // rather than the moment the run started. @@ -7374,31 +5813,24 @@ pub const Pardes = struct { 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| { + panes.Pdf.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); + panes.File.changed(p, id, bytes); return if (p.panes[id]) |current| if (current.file) |file| std.mem.eql(u8, file.content, bytes) else false else false; } - /// 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); @@ -7407,9 +5839,6 @@ pub const Pardes = struct { 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; @@ -7424,9 +5853,6 @@ pub const Pardes = struct { } pub fn nextEffect(p: *Pardes) ?Effect { - // Before the emptiness test, not after: a pane whose tail is parked - // must not read as "no effects left" while the host's drain loop is - // still asking. This is what turns a truncated paste into a delayed one. p.refillPendingWrites(); if (p.effects_len == 0) { p.effects_head = 0; @@ -7470,11 +5896,6 @@ pub const Pardes = struct { p.pending_write[id] = null; } - /// 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), @@ -7497,31 +5918,21 @@ pub const Pardes = struct { 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. Public because - /// `pty/status` reports it: it is the one field of that file the core does - /// not own itself, and asking here rather than reaching for the vtable in - /// acmefs keeps the null-method default in one place. pub fn hostTtyTaken(p: *const Pardes, id: usize) bool { - const f = p.host.vtable.pull_tty_taken orelse return false; + const f = p.host.vtable.tty_taken orelse return false; return f(p.host.ctx, @intCast(id)); } fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void { - if (p.host.vtable.push_write_file) |f| return f(p.host.ctx, pane, path, bytes); + if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes); // The in-process filesystem reports the same way a real host does, so // an OOM here leaves the pane dirty rather than looking saved. if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, "save", error.OutOfMemory); } - /// A HOST'S ANSWER TO `save_file`, and the only one it needs to give: the - /// write did not happen. Puts the reason on the pane's message row and - /// takes back the optimistic clean mark `perform` made, so the tag keeps - /// its ` *` and the edits keep being edits. See host.zig - /// `push_write_file` for why this is a call and not a return value. pub fn saveFailed(p: *Pardes, id: u8, what: []const u8, err: anyerror) void { if (p.panes[id]) |pane| if (pane.file) |*f| { - // The `-%` spelling acmefs.zig already uses for "make this dirty". + // The `-%` spelling fs.zig already uses for "make this dirty". f.saved_revision = f.revision -% 1; }; p.reportError(id, what, err); @@ -7534,14 +5945,7 @@ pub const Pardes = struct { 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 { + pub fn fsPayload(p: *const Pardes, r: filesystem.Reply) []const u8 { return switch (r.payload) { .none => &.{}, .staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)], @@ -7555,120 +5959,85 @@ pub const Pardes = struct { }; } - /// 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 { + .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its // screen, and the bytes are dropped rather than transcribed. - .write => |w| if (v.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), - // A host with no signal method has no child to signal: the - // fallback host's ptys are silent (see `.write` above), so there - // is nothing to record and nothing to lie about. - .signal_pty => |s| if (v.push_pty_signal) |f| f(p.host.ctx, s.pane, s.sig), - .open_link => |u| if (v.push_open_link) |f| + .write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), + .resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), + .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), + .open_link => |u| if (v.open_link) |f| f(p.host.ctx, u.slice()) else p.fallback.setLink(u.slice()), .save_file => |sf| { const pane = p.panes[sf.pane] orelse return; const f = if (pane.file) |*file| file else return; - // CLEAN HERE rather than where the effect was queued, and - // BEFORE the call so the host can take it back. `saveFile` used - // to set `saved_revision` at emit time, so a write that could - // not happen still cleared the tag's ` *` and left the edits one - // `Del` from gone — `Del` makes no dirty check. Here is also - // where `f.content` is read, so the revision recorded is the - // revision of the bytes that actually went out. + const serial = pane.serial; f.saved_revision = f.revision; p.hostWriteFile(sf.pane, f.path, f.content); + const saved_pane = p.panes[sf.pane] orelse return; + if (saved_pane.serial != serial) return; + const saved = if (saved_pane.file) |*file| file else return; + if (saved.saved_revision != saved.revision or !saved.watch_after_save) return; + saved.watch_after_save = false; + if (filesystem.localPath(saved.path) != null) + p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } }); }, .save_text => |st| { const pane = p.panes[st.pane] orelse return; if (pane.serial != st.serial) return; // a recycled slot: not ours - // `Save ` is a COPY: it does not clean this pane, - // because the file the pane has open is not the file that was - // written. The one case that does clean is a scratch buffer - // adopting the path, and `saveTo` handles that by emitting - // `save_file` for the pane's own path instead. 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; + const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); p.hostWriteFile(st.pane, st.path.slice(), text); }, .write_dump => { const out = p.dump_out orelse return; - if (v.push_write_dump) |f| { + if (v.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); + _ = p.fallback.writeFile(host_io.Fallback.dump_path, out); + p.setLastDump(host_io.Fallback.dump_path); } }, .set_clipboard => { const text = p.yank orelse ""; - if (v.push_set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); + if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); }, // No desktop to ask: answer from the in-process clipboard at once, // which is the same shape as a host answering later. - .read_clipboard => if (v.pull_read_clipboard) |f| + .read_clipboard => if (v.read_clipboard) |f| f(p.host.ctx) else p.update(.{ .paste = p.fallback.clipboard.items }), - .lsp => |q| if (v.pull_lsp) |f| + .lsp => |q| if (v.lsp) |f| f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() }) else p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), - .pipe => |q| if (v.pull_pipe) |f| + .pipe => |q| if (v.pipe) |f| f(p.host.ctx, q.id) else p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), - .watch => |w| if (v.push_watch_file) |f| - f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on) + .watch => |w| if (v.watch_file) |f| + f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode) else { p.fallback.watched[w.pane] = w.on; }, - .theme_file => |t| if (v.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 ONE EFFECT NO HOST METHOD CAN SERVE: attaching REPLACES the - // core this call is running inside — `pump` is two frames up the - // stack — so all it may do here is record the request where the - // shell's outer loop finds it, which is Restore's shape exactly. - // Reaching it through the ring rather than straight from the - // builtin is what ORDERS it: a `Save` queued by the same update is - // performed first, so nothing you typed is still unwritten when - // the screen changes owners. A shell that never polls (the - // browser, the board) simply cannot attach, which is the truth - // about a machine with no unix socket to attach to. + .theme_file => |t| if (v.watch_theme) |f| f(p.host.ctx, t.generation, t.on), + .dump_themes => |d| if (v.dump_themes) |f| f(p.host.ctx, d.pane), + .fs_reply => {}, .attach => |a| { const name = a.name.slice(); @memcpy(p.attach_buf[0..name.len], name); p.attach_req = .{ .pane = a.pane, .name = p.attach_buf[0..name.len] }; }, - // ...and its counterpart, which a host CAN serve and usually does - // not. Only a detached core's host fills `push_detach` in; a local - // tty or SDL shell leaves it null, and the honest answer there is - // a message row rather than a frontend that quits or a word that - // silently does nothing. A null-method fallback and not a comptime - // gate, because whether there is a session to leave is a fact - // about this RUN — the same binary attaches one minute and does - // not the next. - .detach => |d| if (v.push_detach) |f| + .detach => |d| if (v.detach) |f| f(p.host.ctx) else p.reportError(d.pane, "detach", error.NotAttached), @@ -7677,45 +6046,29 @@ pub const Pardes = struct { } } - /// 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); + if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); // A quitting frame has already freed what it would draw. if (p.quit) return; - if (v.push_poll_frame) |f| f(h.ctx); + if (v.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`. + if (v.present) |f| f(h.ctx, surface); + if (v.post_present) |f| f(h.ctx); } 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 => {}, + .fs_req => |req| if (req.changesPane()) { + p.raw_hover_intent = false; + p.cancelLookHover(); + }, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { p.raw_hover_intent = false; p.cancelLookHover(); @@ -7737,38 +6090,15 @@ pub const Pardes = struct { 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', @@ -7776,26 +6106,23 @@ pub const Pardes = struct { }; switch (ev) { .resize => |sz| { - p.snap_panel_layout_once = true; + p.presentation.snap_once = true; if (comptime pdf_enabled) { - var before: [MAX_PANES]?pdf_pane.Viewport = @splat(null); + var before: [MAX_PANES]?panes.Pdf.Viewport = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; - if (pane.pdf != null) before[id] = pdf_pane.paneViewport(p, pane); + if (pane.pdf != null) before[id] = panes.Pdf.paneViewport(p, pane); } p.screen_w = sz.cols; p.screen_h = sz.rows; p.cell_pixels.w = @max(1, sz.cell_pixels.w); p.cell_pixels.h = @max(1, sz.cell_pixels.h); - // 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(); + layout.compute(p); 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); + if (!std.meta.eql(before[id], panes.Pdf.paneViewport(p, pane))) { + panes.Pdf.captureLayoutAnchor(pv); pv.layout_valid = false; pv.search_reveal_pending = pv.search_query.len > 0; } @@ -7809,25 +6136,20 @@ pub const Pardes = struct { }, .output => |o| { const pane = p.panes[o.pane] orelse return; - // The RAW bytes, before the emulator eats them. `pty/data`'s - // read is the only thing that wants them — the grid is a - // rendering and cannot be un-rendered — and this is one load - // and one branch on a pane nobody is reading. See - // `acmefs.notePtyOutput`. - acmefs.notePtyOutput(p, o.pane, o.bytes); - term_pane.feedOutput(p, pane, o.bytes); + filesystem.notePtyOutput(p, o.pane, o.bytes); + panes.Terminal.feedOutput(p, pane, o.bytes); + }, + .eof => |e| p.removePane(e.pane) catch |err| { + if (p.panes[e.pane]) |pane| pane.mode = .normal; + p.reportError(e.pane, "terminal exited; Del retries close", err); }, - .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, r.failure), .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, + .border_v, .border_h, .move => p.presentation.snap_once = true, else => {}, }; p.handleMouse(m); @@ -7836,24 +6158,21 @@ pub const Pardes = struct { 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)); + if (pane.hasPdf() and p.native_images) + _ = panes.Pdf.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) }), + .fs_req => |r| p.emit(.{ .fs_reply = filesystem.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.presentation.advance(); p.advanceLookHover(); }, } @@ -7862,15 +6181,6 @@ pub const Pardes = struct { 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| { @@ -7879,30 +6189,14 @@ pub const Pardes = struct { 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); + filesystem.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"; @@ -7915,16 +6209,6 @@ pub const Pardes = struct { 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 = {} }); @@ -7933,32 +6217,19 @@ pub const Pardes = struct { /// 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)) { + if (panes.Terminal.bracketedPaste(pane)) { p.emitWrite(id, "\x1b[200~"); p.emitWrite(id, text); p.emitWrite(id, "\x1b[201~"); @@ -7971,11 +6242,6 @@ pub const Pardes = struct { 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; @@ -7984,30 +6250,19 @@ pub const Pardes = struct { 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), + .after => p.pasteText(pane, bytes, false, 1), + .before => p.pasteText(pane, bytes, true, 1), .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 { @@ -8019,11 +6274,6 @@ pub const Pardes = struct { 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; @@ -8032,16 +6282,6 @@ pub const Pardes = struct { // ---- 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( @@ -8049,9 +6289,9 @@ pub const Pardes = struct { "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.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { - if (output_pane.fileTraits(f.output).saves and f.revision != f.saved_revision) + if (panes.Output.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); } @@ -8064,13 +6304,6 @@ pub const Pardes = struct { 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; @@ -8084,60 +6317,6 @@ pub const Pardes = struct { 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; @@ -8148,25 +6327,19 @@ pub const Pardes = struct { 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, " ")); + const q_end = panes.File.displayWidth(p.tagPrefix(q) catch continue) + lead + panes.File.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 gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.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], ' '); @@ -8174,14 +6347,11 @@ pub const Pardes = struct { 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)); + const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.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); @@ -8189,11 +6359,6 @@ pub const Pardes = struct { 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); @@ -8203,28 +6368,19 @@ pub const Pardes = struct { pane.tag_sel = false; // a one-line tag has no use for normal mode: always insert pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); 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)))); + pane.tag_col = @intCast(@min(text.len, panes.File.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; @@ -8240,9 +6396,6 @@ pub const Pardes = struct { 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) { @@ -8254,9 +6407,6 @@ pub const Pardes = struct { 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 @@ -8270,9 +6420,6 @@ pub const Pardes = struct { } 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) { @@ -8311,29 +6458,19 @@ pub const Pardes = struct { /// 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 { + fn setTagRange(pane: *Pane, text: []const u8, r: modal.Selection) void { const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); - pane.tag_col = @intCast(modal.hxCursor(text, r)); + pane.tag_col = @intCast(modal.selectionCursor(text, r)); pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi)); } - /// 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; @@ -8342,23 +6479,10 @@ pub const Pardes = struct { 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)) { @@ -8375,15 +6499,6 @@ pub const Pardes = struct { 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; @@ -8391,11 +6506,6 @@ pub const Pardes = struct { 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; @@ -8411,14 +6521,7 @@ pub const Pardes = struct { 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 { + fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.Selection { const target: ?usize = if (hit(key, config.line_move_left)) modal.prevGrapheme(text, cur) else if (hit(key, config.line_move_right)) @@ -8447,16 +6550,9 @@ pub const Pardes = struct { else null; const t = wt orelse return null; - return modal.hxWordMove(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); + return modal.moveWord(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); @@ -8467,19 +6563,12 @@ pub const Pardes = struct { 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| { @@ -8503,7 +6592,7 @@ pub const Pardes = struct { else if (hit(key, config.topbar_right)) modal.nextGrapheme(bar, cur) else if (lineMotion(bar, cur, key)) |r| - modal.hxCursor(bar, r) + modal.selectionCursor(bar, r) else null; // never past the last cell: there is nothing to append up here, so the @@ -8519,7 +6608,7 @@ pub const Pardes = struct { /// 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 { + fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { const arena = p.scratch.allocator(); const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); @@ -8532,8 +6621,8 @@ pub const Pardes = struct { 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)); + const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len); + const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len)); total += b1 - b0 + @intFromBool(selected > 0); selected += 1; } @@ -8549,18 +6638,14 @@ pub const Pardes = struct { 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)); + const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len); + const b1 = modal.nextGrapheme(line, @min(panes.File.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 }; @@ -8570,10 +6655,6 @@ pub const Pardes = struct { 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); @@ -8585,7 +6666,7 @@ pub const Pardes = struct { /// 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 { + fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { var sl = at; if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; const text = p.paneText(pane) catch return null; @@ -8594,10 +6675,10 @@ pub const Pardes = struct { 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 col = panes.File.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); + sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo)); + sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1); return sl; } return null; @@ -8605,16 +6686,16 @@ pub const Pardes = struct { /// 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 { + fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { const w = pane.wrapRow(pane.cur_row, pane.cur_col); const vrow = w.row + @as(i32, BOX_H); const vcol = if (pane.file != null) - file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) + panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane)) 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))); + const lines = p.paneCursorLines(pane) catch break :blk pane.cur_col; + const local = pane.cur_row; + if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col; + break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); }; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } @@ -8628,30 +6709,14 @@ pub const Pardes = struct { return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } - /// Run one acme chord (`Look`/`Exec`) once per EXPLICIT selection, in - /// document order. False when there is only the primary range, which is - /// the caller's cue to take its own single-selection path unchanged. - /// - /// The bytes of every range are copied BEFORE the first builtin runs, for - /// the reason `submitPipe` copies too: a `Look` opens panes and an `Exec` - /// can run a builtin that edits or closes the very pane these offsets are - /// into. After that the loop owns nothing of the pane but its slot, and - /// re-checks even that. - /// - /// FOCUS FOLLOWS THE PRIMARY, not the last range. `lookAt` sets `p.active` - /// for every target it opens, so without this the pane you end up looking - /// at is whichever selection happened to sort last — an accident rather - /// than an answer. `Exec` moves focus for neither, so this costs it - /// nothing. fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { if (pane.nsel == 0) return false; - const pl = p.paneCursorLines(pane) catch return false; - const text = p.flatSurface(pane, pl) catch return false; - var ranges: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, text, pl.row0, &ranges); + const text = p.flatSurface(pane) catch return false; + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(text, 0, &ranges); if (got.n < 2) return false; - var texts: [MAX_SELS][]u8 = undefined; + var texts: [Pane.max_selections][]u8 = undefined; var made: usize = 0; defer for (texts[0..made]) |t| p.gpa.free(t); for (ranges[0..got.n]) |range| { @@ -8675,9 +6740,6 @@ pub const Pardes = struct { for (texts[0..made], 0..) |txt, i| { p.runBuiltin(cmd, id, "", txt); if (i == got.pri) primary_active = p.active; - // The pane this loop is standing on can be closed by what it just - // ran — a selection whose text is `Del` is a legal Exec. Same - // slot-and-serial re-check `replaySels` makes for the same reason. const still = p.panes[id] orelse break; if (still.serial != serial) break; } @@ -8694,19 +6756,16 @@ pub const Pardes = struct { /// 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, + preview: ?Pane.Sel = null, /// File words stay logical so a soft-wrapped operand is not cut into /// unrelated rendered fragments. Null for tags and non-file panes. file_word: ?FileWordSpan = null, /// A newly expanded word is installed in the gesture's button slot; /// an existing selection is only borrowed and must not replace it. - expanded: ?Sel = null, + expanded: ?Pane.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 { + fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { const visible = clicked.r0 - @as(i32, BOX_H); const wrapped = pane.wrapAt(visible); const row = wrapped.line; @@ -8715,7 +6774,7 @@ pub const Pardes = struct { pane, wrapped.line, wrapped.at, - clicked.c0 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0), + clicked.c0 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ) else clicked.c0; @@ -8742,7 +6801,7 @@ pub const Pardes = struct { 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 line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); const b = expandedWord(line, source_col) orelse return result; const lo = std.math.cast(i32, b.lo) orelse return result; @@ -8750,18 +6809,14 @@ pub const Pardes = struct { 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.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); expanded.r1 = last.row + @as(i32, BOX_H); - expanded.c1 = @as(i32, config.PREFIX_W) + file_pane.displayEndOffset(pane, row, last.at, hi - 1); + expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); } result.preview = expanded; result.expanded = expanded; @@ -8776,13 +6831,6 @@ pub const Pardes = struct { 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) { @@ -8806,14 +6854,6 @@ pub const Pardes = struct { 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; @@ -8822,21 +6862,9 @@ pub const Pardes = struct { } 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| { @@ -8856,46 +6884,31 @@ pub const Pardes = struct { 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); + const src = layout.splitParent(p, p.active); + const f = layout.findPane(p, src).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; 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).?; + const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { - _ = p.layoutSplitColumn(p.active, p.active, false); + _ = layout.splitColumn(p, 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); + pane.normal.clear(); + } else panes.Terminal.enterTty(p, p.active); return; } // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. @@ -8907,75 +6920,42 @@ pub const Pardes = struct { 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); + if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) { + const submit = hit(key, config.search_submit); + switch (pane.prompt) { + .pipe => |pipe| { + if (submit) p.submitPipe(p.active); + pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); + }, + .save => |at| { + if (submit) p.submitSave(p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); + }, + .search => |at| { + if (submit) + p.submitSearch(p.active) + else if (selRegexArmed(pane)) |_| + p.applySelRegex(pane, "", false); + pane.tag_tail_len = @min(at, pane.tag_tail_len); + }, + .none => unreachable, + } exitTagEdit(pane); pane.mode = .normal; - pane.pending = 0; + pane.normal.clear(); 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 + const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { - // ONE ACTION PER SELECTION. Both chords used to read the - // PRIMARY range and drop the other cursors on the floor, which - // is the one thing a multi-cursor editor must not do with a - // command the user aimed at every cursor. `chordEachSel` is - // the `submitPipe` shape — `paneRanges` once, forward, copies - // taken before anything runs — and returns false when there is - // nothing multi about this keystroke, which is every keystroke - // with one cursor and therefore the unchanged path below. if (p.chordEachSel(pane, cmd)) return; if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; @@ -8990,10 +6970,7 @@ pub const Pardes = struct { 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); + if (pane.mode == .normal and pane.hasPdf()) return p.handlePdfNormal(pane, key); switch (pane.mode) { .normal => { p.handleNormal(pane, key); @@ -9002,40 +6979,23 @@ pub const Pardes = struct { 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) + pane.normal.clear(); 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 text = p.flatSurface(pane) catch return; + const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); const back = @max(a_off, modal.prevGrapheme(text, gap)); - const bc = modal.hxPos(text, back); + const bc = modal.positionAt(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 sgap = modal.offsetAt(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); + const bp = modal.positionAt(text, b2); s.row = @intCast(bp.row); s.col = @intCast(bp.col); s.arow = s.row; @@ -9046,23 +7006,16 @@ pub const Pardes = struct { } p.handleInsert(pane, key); }, - .tty => term_pane.forwardKey(p, p.active, key), + .tty => panes.Terminal.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| + panes.Output.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| p.reportError(p.active, "help", err); return; } @@ -9083,190 +7036,30 @@ pub const Pardes = struct { } } - /// 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, - }; + // ---- helix-modal normal mode ---- - 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; - } + pub fn paneCursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const arena = p.scratch.allocator(); + if (pane.file) |*f| return panes.File.cursorLines(arena, pane, f); + if (pane.hasPdf()) { + if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena); + unreachable; } - return tc; + return panes.Terminal.cursorLines(p, pane); } - 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)), - )); + fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 { + if (pane.file != null) + return panes.File.byteAtRowDisplay(pane, row, from_raw, display_col); + const lines = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col); + const local = row; + if (local < 0 or @as(usize, @intCast(local)) >= lines.len) + return @max(0, from_raw + display_col); + return @intCast(panes.File.byteAtDisplayFrom( + lines[@intCast(local)], + @intCast(@max(0, from_raw)), + @intCast(@max(0, display_col)), + )); } /// Freeze the live terminal cursor into the modal coordinate space. The @@ -9274,434 +7067,29 @@ pub const Pardes = struct { fn pinPaneCursor(p: *Pardes, pane: *Pane) void { if (pane.cur_pinned) return; pane.pinCursor(); - if (pane.file == null and !hasPdf(pane)) + if (pane.file == null and !pane.hasPdf()) 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 { + fn flatSurface(p: *Pardes, pane: *Pane) ![]const u8 { if (pane.file) |f| return f.content; - if (hasPdf(pane)) { + if (pane.hasPdf()) { 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); + const lines = try panes.Terminal.cursorLines(p, pane); + return panes.Terminal.flatSurface(p, pane, lines); } - // ---- `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); + const text = p.flatSurface(pane) catch return; + const got = pane.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - p.startSearch(pane, if (split) config.split_marker else config.select_marker); + p.startPrompt(pane, .{ .search = 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, @@ -9716,37 +7104,12 @@ pub const Pardes = struct { 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 text = p.flatSurface(pane) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; - var out: [MAX_SELS]modal.HxRange = undefined; + var out: [Pane.max_selections]modal.Selection = 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; @@ -9762,23 +7125,20 @@ pub const Pardes = struct { 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) { + while (at < hay.len and m < Pane.max_selections) { const hit_at = re.matchPos(at, hay) orelse break; if (split) { out[m] = .{ .anchor = piece, .head = from + hit_at.start }; m += 1; piece = from + hit_at.end; } else if (from + hit_at.start != to) { - // 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) { + if (split and piece < to and m < Pane.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } @@ -9791,95 +7151,15 @@ pub const Pardes = struct { 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); + return pane.setRanges(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; + pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } - /// 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, + mode: Pane.Mode, select: bool, - count: u32, - pending: u21, - pending2: u21, - pending_ch: u21, + normal: modal.Normal.State, find_op: u8, find_ch: u21, append_at: @FieldType(Pane, "append_at"), @@ -9888,10 +7168,7 @@ pub const Pardes = struct { return .{ .mode = pane.mode, .select = pane.select, - .count = pane.count, - .pending = pane.pending, - .pending2 = pane.pending2, - .pending_ch = pane.pending_ch, + .normal = pane.normal, .find_op = pane.find_op, .find_ch = pane.find_ch, .append_at = pane.append_at, @@ -9901,10 +7178,7 @@ pub const Pardes = struct { 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.normal = s.normal; pane.find_op = s.find_op; pane.find_ch = s.find_ch; pane.append_at = s.append_at; @@ -9913,41 +7187,29 @@ pub const Pardes = struct { /// what a replayed key does at each cursor const Replay = union(enum) { - normal: normal_input.Action, + normal: modal.Normal.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 list: [Pane.max_selections]Pane.SelRange = undefined; var n: usize = 0; var pri: usize = 0; - const prim: SelRange = .{ + const prim: Pane.SelRange = .{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, .sticky = pane.sticky_col, }; - const pr = selStart(prim); + const pr = Pane.selStart(prim); var placed = false; for (pane.sels[0..pane.nsel]) |s| { - const sr = selStart(s); + const sr = Pane.selStart(s); if (!placed and (pr.row < sr.row or (pr.row == sr.row and pr.col <= sr.col))) { pri = n; list[n] = prim; @@ -9969,7 +7231,7 @@ pub const Pardes = struct { 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; + var res: [Pane.max_selections]struct { cur: usize, anc: usize, sticky: i32 } = undefined; p.multi_on = true; p.multi_stop = false; var passes: usize = 0; @@ -9994,25 +7256,18 @@ pub const Pardes = struct { .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 { + const text = p.flatSurface(pane) 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 co = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const ao = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - pl.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) else co; res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = pane.sticky_col }; @@ -10020,270 +7275,30 @@ pub const Pardes = struct { 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; + const text = p.flatSurface(pane) catch return; + var rs: [Pane.max_selections]modal.Selection = undefined; + var st: [Pane.max_selections]i32 = undefined; for (res[0..n], 0..) |r, k| { - rs[k] = cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); + rs[k] = Pane.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); + pane.setRanges(text, rs[0..n], st[0..n], pri, after_expl); after.into(pane); pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass } - /// 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); - } + return panes.File.wrapWidth(pane, p.settings.wrap); } - /// 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 parsed = modal.Normal.parse(&pane.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, @@ -10293,16 +7308,11 @@ pub const Pardes = struct { 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 { + fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.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); + const text = p.flatSurface(pane) catch return; + const range = pane.primaryRange(text, 0); + const cur = modal.selectionCursor(text, range); switch (semantic) { .escape => { @@ -10311,35 +7321,35 @@ pub const Pardes = struct { }, .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)); + const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Pane.maxLine(text)) else 0; + return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, - .last_line => return 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))), + .last_line => return pane.pointMove(text, range, modal.lineStartOffset(text, Pane.maxLine(text))), + .line_start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .line_end => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; - return pointMove(pane, pl, text, range, ls + nw); + return pane.pointMove(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)), + .line_down => return pane.visualMove(text, range, true, go.count, p.paneWrapWidth(pane)), + .line_up => return pane.visualMove(text, range, false, go.count, p.paneWrapWidth(pane)), .column => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(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)); + const slice = text[ls..modal.lineEndOffset(text, line)]; + return pane.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); }, - .view_top => return 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_top => return pane.gotoWindow(text, range, .top, go.count), + .view_center => return pane.gotoWindow(text, range, .center, go.count), + .view_bottom => return pane.gotoWindow(text, range, .bottom, go.count), }, .view => |view| switch (view) { .top => { @@ -10354,8 +7364,8 @@ pub const Pardes = struct { 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), + .scroll_down => return pane.scrollViewMove(text, range, 1), + .scroll_up => return pane.scrollViewMove(text, range, -1), }, .find => |find| { const op: u8 = switch (find.kind) { @@ -10366,9 +7376,7 @@ pub const Pardes = struct { }; pane.find_op = op; pane.find_ch = find.char; - return findMove( - pane, - pl, + return pane.findMove( text, range, find.char, @@ -10379,26 +7387,27 @@ pub const Pardes = struct { }, .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)); + const lines = p.paneCursorLines(pane) catch return; + const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return; + return pane.pointMove(text, range, modal.offsetAt(text, mc)); }, - .textobject => |object| return p.textobjectSelect(pane, pl, object.char, object.around), + .textobject => |object| return p.textobjectSelect(pane, text, 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), + .surround_delete => |char| return p.surroundDelete(pane, char), + .surround_replace => |replace| return p.surroundReplace(pane, 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); + const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select); + return pane.setRange(text, 0, 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 + if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } - if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) + if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { pane.search_row = null; @@ -10414,21 +7423,21 @@ pub const Pardes = struct { .left => { var target = cur; for (0..move.count) |_| target = modal.prevGrapheme(text, target); - return pointMove(pane, pl, text, range, target); + return pane.pointMove(text, range, target); }, .right => { var target = cur; for (0..move.count) |_| target = modal.nextGrapheme(text, target); - return pointMove(pane, pl, text, range, target); + return pane.pointMove(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), + .down => return pane.verticalMove(text, range, true, move.count), + .up => return pane.verticalMove(text, range, false, move.count), + .next_word_start => return pane.wordMove(text, range, move.count, .next_word_start), + .prev_word_start => return pane.wordMove(text, range, move.count, .prev_word_start), + .next_word_end => return pane.wordMove(text, range, move.count, .next_word_end), + .next_long_word_start => return pane.wordMove(text, range, move.count, .next_long_word_start), + .prev_long_word_start => return pane.wordMove(text, range, move.count, .prev_long_word_start), + .next_long_word_end => return pane.wordMove(text, range, move.count, .next_long_word_end), }, .repeat_find => |count| { if (pane.find_op == 0) return; @@ -10437,45 +7446,45 @@ pub const Pardes = struct { 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; + const cc = modal.selectionCursor(text, repeated); + const target = modal.findTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; repeated = if (pane.select) - modal.hxPutCursor(text, repeated, target, true) + modal.moveSelectionCursor(text, repeated, target, true) else - modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, target, true); + modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true); moved = true; } if (!moved) return; - return setPaneRange(pane, pl, text, repeated, false); + return pane.setRange(text, 0, repeated, false); }, .line => |line_kind| switch (line_kind) { - .start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), + .start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .end => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; - return pointMove(pane, pl, text, range, ls + nw); + return pane.pointMove(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)); + const line = @min(@as(usize, go.count) - 1, Pane.maxLine(text)); + return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, .page => |page| switch (page.kind) { // Counts were parsed historically but these four text view // actions intentionally move exactly one viewport unit. - .half_down => return 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)), + .half_down => return pane.halfPageMove(text, range, true), + .half_up => return pane.halfPageMove(text, range, false), + .down => return pane.scrollViewMove(text, range, @as(i32, pane.rows)), + .up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)), }, .insert => |insert| { const where: InsertAt = switch (insert.kind) { @@ -10495,7 +7504,7 @@ pub const Pardes = struct { } else { pane.select = true; if (pane.msel.active) { - setPaneRange(pane, pl, text, range, true); + pane.setRange(text, 0, range, true); } else if (!pane.vsel.active) { pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; } else { @@ -10504,22 +7513,22 @@ pub const Pardes = struct { } 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), + .line => return pane.lineSelect(text, range, select.count), + .line_bounds => return pane.lineBoundsSelect(text, range), + .shrink_to_line_bounds => return pane.shrinkSelToLineBounds(text, range), + .collapse => return pane.setRange(text, 0, .{ .anchor = cur, .head = cur }, false), .flip => { const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, explicit); + return pane.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit); }, - .all => return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false), + .all => return pane.setRange(text, 0, .{ .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); + return pane.multiSelAction(text, multi.kind, multi.count); }, .select_regex => |split| return p.startSelRegex(pane, split), .edit => |edit| switch (edit.kind) { @@ -10528,14 +7537,8 @@ pub const Pardes = struct { .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); - }, + .paste_after => return p.pasteText(pane, p.yank orelse return, false, edit.count), + .paste_before => return p.pasteText(pane, p.yank orelse return, true, edit.count), .switch_case => return p.normalCase(pane, .toggle), .lowercase => return p.normalCase(pane, .lower), .uppercase => return p.normalCase(pane, .upper), @@ -10563,8 +7566,8 @@ pub const Pardes = struct { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, - .pipe_selection => |how| return p.startPipe(pane, how), - .search => return p.startSearch(pane, config.search_marker), + .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), + .search => return p.startPrompt(pane, .{ .search = config.search_marker }), .search_step => |direction| return p.lookWalk( if (direction == .forward) @as(i32, 1) else -1, ), @@ -10573,50 +7576,29 @@ pub const Pardes = struct { // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- - fn startPipe(p: *Pardes, pane: *Pane, how: normal_input.PipeBehavior) 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(pipeMarker(how))) return; - pane.prompt = .{ .pipe = prompt_at }; - pane.pipe_how = how; - 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; + if (!panes.Output.fileTraits(f.output).saves) return; + const prompt = switch (pane.prompt) { + .pipe => |pipe| pipe, + else => return, + }; const tail = pane.tagSlice(); - const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; - const marker = pipeMarker(pane.pipe_how); + const armed = tail[@min(prompt.at, tail.len)..]; + const marker = pipeMarker(prompt.how); if (!std.mem.startsWith(u8, armed, marker)) return; const command = armed[marker.len..]; if (command.len == 0) return; - var ranges: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, f.content, 0, &ranges); + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(f.content, 0, &ranges); // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. - const ninputs = if (pane.pipe_how.pipes()) got.n else 1; + const ninputs = if (prompt.how.pipes()) got.n else 1; const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; var made: usize = 0; for (ranges[0..ninputs], 0..) |range, i| { - // A behaviour that sends no stdin still submits one input, empty: - // the runner's contract is one invocation per input, and `!` wants - // exactly one invocation with nothing on its stdin. - const bytes = if (pane.pipe_how.pipes()) bytes: { + const bytes = if (prompt.how.pipes()) bytes: { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > f.content.len) break; @@ -10656,7 +7638,7 @@ pub const Pardes = struct { .ranges = ranges, .primary = @intCast(got.pri), .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, - .how = pane.pipe_how, + .how = prompt.how, .nranges = @intCast(got.n), }; p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); @@ -10671,14 +7653,6 @@ pub const Pardes = struct { return null; } - /// Put a failed filter where it can be READ: the command, which selection - /// it was, what became of it, and the command's own stderr underneath. - /// - /// An `+Errors` pane rather than the message row, because a message row is - /// 256 bytes and one line, and the useful half of a shell failure is the - /// text the shell wrote — `sh: line 1: trr: command not found`, a compiler - /// diagnostic, a `jq` parse error with a column in it. Truncating that to - /// fit a row would throw away the reason and keep the label. fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Failure) void { var out: std.Io.Writer.Allocating = .init(p.gpa); defer out.deinit(); @@ -10701,17 +7675,12 @@ pub const Pardes = struct { if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return; } else { // No diagnosis at all: nobody ran it. The detached daemon, the - // browser and the board all leave `pull_pipe` null on purpose. + // browser and the board all leave `pipe` null on purpose. w.writeAll("this session cannot run filters\n") catch return; } const content = out.toOwnedSlice() catch return; - // FOCUS STAYS WITH THE TEXT. `openRead` moves `p.active` to the buffer - // it opens, which is right for a `Grep` you asked to read and wrong for - // a report you did not: a failed filter should put the reason on screen - // and leave the cursor in the file you were filtering, ready to fix the - // command and press `|` again. const was = p.active; - output_pane.openErrors(p, wait.pane, content) catch |err| { + panes.Output.openErrors(p, wait.pane, content) catch |err| { p.gpa.free(content); // Nowhere to put the report is itself worth one line. p.reportError(wait.pane, "pipe", err); @@ -10720,10 +7689,6 @@ pub const Pardes = struct { if (p.panes[was] != null) p.active = was; } - /// WHERE range `i`'s output goes, as a span of the pre-edit content to be - /// replaced by it. The three writing behaviours differ in exactly this and - /// nothing else: `|` swaps the selection out, `!` opens a hole at its - /// start, `A-!` at its end. Null when the range no longer fits the text. fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } { const range = wait.ranges[i]; const lo = @min(range.anchor, range.head); @@ -10736,14 +7701,6 @@ pub const Pardes = struct { }; } - /// WHAT range `i` receives. - /// - /// Two helix rules live here. A behaviour that sends no stdin ran the - /// command ONCE, so every range gets `outputs[0]` — ten cursors and `date` - /// give ten identical stamps rather than ten racing ones. And a command - /// that added a trailing newline to a selection which did not have one has - /// it taken back off, which is what keeps `| tr a-z A-Z` on one line from - /// splitting it in two. fn pipeOutput( wait: *const PendingPipe, outputs: []const []const u8, @@ -10781,16 +7738,10 @@ pub const Pardes = struct { 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) return; - // THE FILE MOVED UNDER THE FILTER. One keystroke during a ten-second - // command was enough to discard the whole result in silence, which is - // indistinguishable from the filter having done nothing at all. + if (!panes.Output.fileTraits(f.output).saves) return; if (f.revision != wait.revision) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); - // `A-|` ran the command FOR ITS EFFECT. There is nothing to splice and - // the selection is left exactly where it was, which is the whole - // difference between it and `|`. if (wait.how == .ignore) return; const n = wait.nranges; @@ -10811,7 +7762,7 @@ pub const Pardes = struct { 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 new_ranges: [Pane.max_selections]modal.Selection = undefined; var read_at: usize = 0; var write_at: usize = 0; for (0..n) |i| { @@ -10824,10 +7775,6 @@ pub const Pardes = struct { @memcpy(replacement[write_at .. write_at + output.len], output); write_at += output.len; const out_end = write_at; - // THE OUTPUT IS WHAT ENDS UP SELECTED, for all three behaviours - // that write — helix `shell()` builds its new range around the - // inserted text, keeping the original's direction, so a `!` can be - // followed straight by another operator on what it just produced. new_ranges[i] = if (range.anchor > range.head) .{ .anchor = out_end, .head = out_start } else @@ -10838,9 +7785,8 @@ pub const Pardes = struct { // 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..n], &.{}, wait.primary, wait.explicit); + panes.File.setContent(p, f, replacement); + pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); pane.select = false; pane.ensureCursorVisible(); } @@ -10850,12 +7796,11 @@ pub const Pardes = struct { 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. + pub fn paneDir(pane: *const Pane) []const u8 { switch (pane.cwd) { .inherited => |src| return paneDir(src), - else => {}, + .owned => |cwd| return cwd, + .none => {}, } if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; if (comptime pdf_enabled) if (pane.pdf) |pv| @@ -10864,41 +7809,47 @@ pub const Pardes = struct { 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 { + pub fn startPrompt(p: *Pardes, pane: *Pane, request: union(enum) { + search: []const u8, + pipe: modal.Normal.PipeBehavior, + save, + }) void { + const marker = switch (request) { + .search => |marker| marker, + .save => config.save_marker, + .pipe => |how| pipe: { + const file = pane.file orelse return; + if (!panes.Output.fileTraits(file.output).saves) return; + break :pipe pipeMarker(how); + }, + }; p.seedTail(pane); if (!pane.tag_init) return; + const dir = if (request == .save) paneDir(pane) else ""; + const slash = request == .save and (dir.len == 0 or dir[dir.len - 1] != '/'); + var room = pane.tag_tail.len - pane.tag_tail_len; + if (marker.len > room) return; + room -= marker.len; + if (dir.len > room) return; + room -= dir.len; + if (slash and room == 0) return; + const prefix_len = (p.tagPrefix(pane) catch return).len; const prompt_at: u16 = @intCast(pane.tag_tail_len); - 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.appendTag(marker); + if (request == .save) { + _ = pane.appendTag(dir); + if (slash) _ = pane.appendTag("/"); + } + pane.prompt = switch (request) { + .search => .{ .search = prompt_at }, + .pipe => |how| .{ .pipe = .{ .at = prompt_at, .how = how } }, + .save => .{ .save = prompt_at }, + }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; - pane.pending = 0; - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); + pane.normal.clear(); + pane.tag_col = @intCast(prefix_len + pane.tag_tail_len); } /// Enter on a save input: the path is everything past the marker. @@ -10922,35 +7873,9 @@ pub const Pardes = struct { 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 - // NOT marked saved here: the effect has only been QUEUED. `perform`'s - // `.save_file` arm cleans the pane if and only if the host says the - // bytes landed — see there. p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); } - /// 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 @@ -10968,16 +7893,16 @@ pub const Pardes = struct { if (pane.isTerminal()) p.askWrite(id, pane.serial, full); return; }; - if (f.output != null and output_pane.fileTraits(f.output).saves) { + if (f.output != null and panes.Output.fileTraits(f.output).saves) { const owned = p.gpa.dupe(u8, full) catch return; p.gpa.free(f.path); f.path = owned; f.output = null; // an ordinary file pane from here on - pane.cwd = .none; // its directory is now its own path's dirname + f.watch_after_save = true; + pane.clearCwd(); pane.tag_init = false; // re-derive the tag as a plain file pane.tag_tail_len = 0; p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); - p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); return; } // its own path, spelled out: the in-place write, so the pane comes clean @@ -10985,15 +7910,8 @@ pub const Pardes = struct { 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); + const SavePath = Effect.Buf(effect_path_cap); - /// 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), @@ -11002,16 +7920,8 @@ pub const Pardes = struct { } }); } - /// 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)..]; @@ -11025,33 +7935,13 @@ pub const Pardes = struct { 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); @@ -11064,30 +7954,6 @@ pub const Pardes = struct { 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"); @@ -11096,16 +7962,11 @@ pub const Pardes = struct { // 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); + const out = try arena.alloc(u8, filesystem.search_max_output_bytes); var out_len: usize = 0; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { - // 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| { @@ -11130,12 +7991,12 @@ pub const Pardes = struct { nroots += 1; } for (roots[0..nroots]) |r| - out_len += try look.grep(arena, p.gpa, r, dir, pat, out[out_len..]); + out_len += try filesystem.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)) { + out_len = try filesystem.find(arena, dir, pat, out); + } else if (pane.hasPdf()) { if (comptime pdf_enabled) { - const found = try pdf_pane.searchRows( + const found = try panes.Pdf.searchRows( &pane.pdf.?, p.pdf_gpa, arena, @@ -11148,7 +8009,7 @@ pub const Pardes = struct { anchor = found.anchor; } } else { - const pl = try p.paneCursorLines(pane); + const lines = 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; @@ -11158,12 +8019,9 @@ pub const Pardes = struct { 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| { + for (lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), @@ -11178,31 +8036,14 @@ pub const Pardes = struct { 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) { + const from: panes.Output.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. + try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); + } + fn searchStep(p: *Pardes, id: usize, delta: i32) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; @@ -11210,32 +8051,47 @@ pub const Pardes = struct { 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); + const tr = panes.Output.fileTraits(rf.output); if (!tr.steps) return false; // fresh results: n starts at the first row, N has nothing behind it - const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n")); + const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") + + @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\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 + _ = p.jumpResult(id, @intCast(step)); + p.active = id; + return true; + } + + fn jumpResult(p: *Pardes, id: usize, row: usize) bool { + const pane = p.panes[id] orelse return false; + const rid = pane.search_pane orelse return false; + const rp = p.panes[rid] orelse return false; + const rf = if (rp.file) |*f| f else return false; + const tr = panes.Output.fileTraits(rf.output); + const ln = modal.lineSlice(rf.content, row); + if (ln.len == 0) return false; + const r: i32 = @intCast(row); + pane.search_row = row; rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.nsel = 0; rp.cur_row = r; rp.cur_pinned = true; - // 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); + look.lineSpan(p, ln, paneDir(rp), &realbuf) orelse blk: { + const target = panes.Output.location(ln); + break :blk if (target.at.line > 0) + look.Span{ .start = 0, .end = target.end } + else + null; + }; if (span) |selected| { rp.cur_col = @intCast(selected.start); rp.look_at = .{ @@ -11247,55 +8103,30 @@ pub const Pardes = struct { 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]); + } else if (span) |selected| { + p.lookAt(rid, ln[selected.start..selected.end]); } - // 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; + p.armLookWalk(rid); 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) - // A rename's argument becomes an identifier in someone's source. Zig - // buffers get Zig's exact rule; any other language the client speaks - // gets the weakest honest one (no whitespace, no quotes — the server - // validates the rest and answers nothing when it hates the name). if (kind == .rename) { const zig_buf = if (pane.file) |f| std.mem.endsWith(u8, f.path, ".zig") else true; if (zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return; if (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null) return; } - const off = if (pane.file) |f| modal.hxOff(f.content, .{ + const off = if (pane.file) |f| modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }) else 0; @@ -11321,14 +8152,6 @@ pub const Pardes = struct { const LspEdit = struct { start: usize, end: usize, text: []const u8 }; - /// Parse a mutating response into ordered replacements. Two record forms, - /// never mixed: `@edit START END` substitutes `fallback` at every range - /// (the ZLS rename path — the text is the request's own argument), and - /// `@put START END PCT` carries its own percent-encoded replacement (a - /// protocol server's rename or format, whose per-range text only the - /// server knows). `fallback == null` rejects the @edit form outright — a - /// format response has no argument to substitute. Anything malformed - /// parses to null and null changes nothing. fn parseLspEdits(p: *Pardes, bytes: []const u8, fallback: ?[]const u8) ?[]LspEdit { if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null; const a = p.scratch.allocator(); @@ -11384,10 +8207,6 @@ pub const Pardes = struct { return out[0..n]; } - /// Where the cursor lands after `edits` replace their ranges: text before - /// the first edit keeps its offset, text between edits shifts by the - /// accumulated delta, and a cursor inside a replaced range clamps into - /// the replacement. fn mapLspEditOffset(edits: []const LspEdit, old: usize) usize { var old_at: usize = 0; var new_at: usize = 0; @@ -11402,10 +8221,6 @@ pub const Pardes = struct { return new_at + (old - old_at); } - /// Apply parsed edit records as ONE undo transaction, or nothing: ranges - /// must be ordered, non-overlapping and in bounds, and the file revision - /// must still be the one the worker was asked about — the user may have - /// typed while the server thought. True when the buffer changed. fn applyLspEdits(p: *Pardes, pane: *Pane, revision: u32, edits: []const LspEdit) bool { const f = if (pane.file) |*file| file else return false; if (f.revision != revision) return false; @@ -11426,7 +8241,7 @@ pub const Pardes = struct { const final_len = std.math.add(usize, f.content.len - removed, added) catch return false; const replacement = p.gpa.alloc(u8, final_len) catch return false; - const old_cursor = modal.hxOff(f.content, .{ + const old_cursor = modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }); @@ -11443,8 +8258,8 @@ pub const Pardes = struct { @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); + panes.File.setContent(p, f, replacement); + const cursor = modal.positionAt(f.content, mapped_cursor); pane.cur_row = @intCast(cursor.row); pane.cur_col = @intCast(cursor.col); pane.vsel.active = false; @@ -11455,21 +8270,11 @@ pub const Pardes = struct { return true; } - /// A worker answered. The two MUTATING kinds are consumed first: a - /// response made of edit records is applied atomically (rename substitutes - /// the argument or the server's own text, `=` applies the formatter), and - /// only a response that is NOT records renders — a rename that spans other - /// files arrives as location rows and opens as a PREVIEW list instead of - /// being half-applied, and a format that could not run stays prose. - /// 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 { + pub fn lspResponse(p: *Pardes, id: u32, response: ?[]const u8) void { const w = p.lsp_wait orelse return; if (w.id != id) return; // superseded by a newer press, or the pane died p.lsp_wait = null; + const rows = response orelse return; const pane = p.panes[w.pane] orelse return; if (pane.serial != w.serial) return; if (w.kind == .rename or w.kind == .format) { @@ -11489,107 +8294,51 @@ pub const Pardes = struct { } else return; } 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 from: panes.Output.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); - if (output_pane.traits(from).jumps and nrows == 1) { + if (panes.Output.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]); + return p.lookAt(w.pane, ln[0..panes.Output.location(ln).end]); } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const content = p.gpa.dupe(u8, rows) catch return; - // 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| + panes.Output.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| { p.reportError(w.pane, "language response", err); + return; + }; + if (panes.Output.traits(from).jumps) { + const result = p.panes[pane.search_pane orelse return] orelse return; + const file = pane.file orelse return; + const row = panes.Output.nextResult(result.file.?.content, lsp.rel(dir, file.path), .{ + .line = @as(usize, @intCast(@max(0, w.row))) + 1, + .col = @as(usize, @intCast(@max(0, w.col))) + 1, + }); + _ = p.jumpResult(w.pane, row); + } } // ---- n/N: the walk over look-able text ---- - /// 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; @@ -11624,13 +8373,6 @@ pub const Pardes = struct { 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; @@ -11638,9 +8380,6 @@ pub const Pardes = struct { 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; @@ -11648,33 +8387,11 @@ pub const Pardes = struct { 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); + fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot { + const lines = p.paneCursorLines(pane) catch return null; + const nrows: i32 = @intCast(lines.len); if (nrows == 0) return null; - const grain: output_pane.Grain = if (pane.file) |*f| output_pane.grain(f.output) else .word; + const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word; const dir = paneDir(pane); var realbuf: [4096]u8 = undefined; const start = std.math.clamp(from.row, 0, nrows - 1); @@ -11682,18 +8399,18 @@ pub const Pardes = struct { while (r >= 0 and r < nrows) : (r += delta) { if (budget.* == 0) return null; budget.* -= 1; - const ln = pl.lines[@intCast(r)]; + const ln = lines[@intCast(r)]; const on_start = r == start; switch (grain) { .word => { - var best: ?LookSpot = null; + var best: ?Pane.LookSpot = null; var i: usize = 0; while (i < ln.len) { while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; const t0 = i; while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; if (i == t0) break; - const sp = look.lookableSpan(ln[t0..i], dir, &realbuf) orelse continue; + const sp = look.wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue; const col0: i32 = @intCast(t0 + sp.start); if (!lookPast(col0, from, on_start, delta)) continue; best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) }; @@ -11705,7 +8422,7 @@ pub const Pardes = struct { // scan past: the row either offers it or it does not .line, .whole => { const sp = (if (grain == .line) - look.lookableLineSpan(ln, dir, &realbuf) + look.lineSpan(p, ln, dir, &realbuf) else wholeRowSpan(ln)) orelse continue; const col0: i32 = @intCast(sp.start); @@ -11717,56 +8434,10 @@ pub const Pardes = struct { 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| @@ -11774,10 +8445,6 @@ pub const Pardes = struct { 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) { @@ -11791,11 +8458,7 @@ pub const Pardes = struct { } } - /// 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 { + fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.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; @@ -11810,16 +8473,13 @@ pub const Pardes = struct { } /// 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 { + fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?panes.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); + if (pane.image != null or pane.hasPdf()) return null; + return panes.Terminal.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 { + fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?panes.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; @@ -11829,14 +8489,14 @@ pub const Pardes = struct { /// 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); + if (pane.file) |*f| return panes.File.setContent(p, f, new); + panes.Terminal.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; + if (pane.hasPdf()) 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 @@ -11844,19 +8504,15 @@ pub const Pardes = struct { pane.select = false; pane.append_at = null; pane.sticky_col = -1; - const pl = p.paneCursorLines(pane) catch { + const text = p.flatSurface(pane) catch { pane.mode = .insert; pane.msel.active = false; pane.vsel.active = false; - pane.pending = 0; + pane.normal.clear(); 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 cur = pane.toModalCursor(); + const line = panes.File.textLine(pane, text, cur.row); const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null; switch (where) { .at => { @@ -11873,19 +8529,19 @@ pub const Pardes = struct { 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); + const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); + const gc = modal.positionAt(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.fromModalCursor(.{ .row = cur.row, .col = modal.firstNonWs(line) }); pane.vsel.active = false; }, .line_end => { - pane.cur_col = @intCast(llen); + pane.cur_col = @intCast(line.len); pane.cur_pinned = true; pane.vsel.active = false; }, @@ -11895,7 +8551,7 @@ pub const Pardes = struct { 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 ind = modal.indentText(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; @@ -11922,28 +8578,23 @@ pub const Pardes = struct { 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); + const t2 = p.flatSurface(pane) catch break :opened; + var rs: [Pane.max_selections]modal.Selection = undefined; + const m = @min(cnt, Pane.max_selections); 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 }); + const o = modal.offsetAt(t2, .{ .row = @intCast(@max(0, pane.cur_row) + @as(i32, @intCast(k))), .col = ind.len }); rs[k] = .{ .anchor = o, .head = o }; } - setPaneRanges(pane, pl2, t2, rs[0..m], &.{}, 0, false); + pane.setRanges(t2, rs[0..m], &.{}, 0, false); } }, } pane.mode = .insert; pane.msel.active = false; - pane.pending = 0; + pane.normal.clear(); 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 }); @@ -11954,38 +8605,30 @@ pub const Pardes = struct { 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 lines = p.paneCursorLines(pane) catch return; + const cur0 = pane.toModalCursor(); 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), + Key.left => modal.charLeft(lines, cur0), + Key.right => modal.charRight(lines, cur0), + Key.up => Pane.insertVerticalCursor(lines, cur0, false), + Key.down => Pane.insertVerticalCursor(lines, cur0, true), else => cur0, }; - fromModalCursor(pane, pl, nc); + pane.fromModalCursor(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 flat = p.flatSurface(pane) catch return; + const range = pane.primaryRange(flat, 0); const step: i32 = @intCast(@max(1, pane.rows)); - scrollViewMove(pane, pl, flat, range, if (key.cp == Key.page_down) step else -step); + pane.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { @@ -11996,9 +8639,9 @@ pub const Pardes = struct { }, 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); + const text = p.flatSurface(pane) catch return; + const cur0 = pane.toModalCursor(); + pane.cur_col = @intCast(panes.File.textLine(pane, text, cur0.row).len); pane.cur_pinned = true; pane.ensureCursorVisible(); return; @@ -12023,19 +8666,16 @@ pub const Pardes = struct { } // 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); + const g = modal.offsetAt(e2.text, c2); if (g == 0) return; - const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); + const wr = modal.moveWord(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 fc = modal.positionAt(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; @@ -12045,19 +8685,16 @@ pub const Pardes = struct { 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 g = modal.offsetAt(e2.text, c2); + const wr = modal.moveWord(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; + const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.positionAt(e2.text, to)) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; @@ -12080,7 +8717,7 @@ pub const Pardes = struct { switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); - const indent = modal.hxNewlineIndentWidth(line, c.col); + const indent = modal.newlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @@ -12134,31 +8771,10 @@ pub const Pardes = struct { 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; @@ -12169,9 +8785,6 @@ pub const Pardes = struct { } } - /// 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 = .{ @@ -12189,7 +8802,7 @@ pub const Pardes = struct { 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 { + fn cellBounds(s: Pane.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 }; @@ -12199,30 +8812,23 @@ pub const Pardes = struct { 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 ""; - }; + const text = p.flatSurface(pane) 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 lines = 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; + if (i >= 0 and @as(usize, @intCast(i)) < lines.len) + total += lines[@intCast(i)].len; } const out = arena.alloc(u8, total) catch return ""; var at: usize = 0; @@ -12232,8 +8838,8 @@ pub const Pardes = struct { out[at] = '\n'; at += 1; } - if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) { - const line = pl.lines[@intCast(i)]; + if (i >= 0 and @as(usize, @intCast(i)) < lines.len) { + const line = lines[@intCast(i)]; @memcpy(out[at..][0..line.len], line); at += line.len; } @@ -12262,7 +8868,7 @@ pub const Pardes = struct { p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; - clampCursor(pane, d.content, eb.row0); + pane.clampCursor(d.content, eb.row0); return; } if (pane.msel.active) { @@ -12321,32 +8927,17 @@ pub const Pardes = struct { } } - /// 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 { + fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool, count: usize) 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; + if (count > 1) { + const total = std.math.mul(usize, count, 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); + for (0..count) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); y = buf; } const b: Bounds = if (pane.vsel.active) @@ -12357,9 +8948,6 @@ pub const Pardes = struct { 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) { @@ -12420,7 +9008,7 @@ pub const Pardes = struct { 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 ind = modal.indentText(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; @@ -12431,7 +9019,7 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); return; } @@ -12445,9 +9033,9 @@ pub const Pardes = struct { p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; - clampCursor(pane, d.content, eb.row0); + pane.clampCursor(d.content, eb.row0); pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); return; } if (pane.msel.active) { @@ -12492,7 +9080,7 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); } else { p.pushUndo(pane); @@ -12506,18 +9094,10 @@ pub const Pardes = struct { 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; + pane.normal.clear(); } } - // ---- 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); @@ -12553,7 +9133,7 @@ pub const Pardes = struct { 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 before = pane.primaryRange(eb.text, eb.row0); const lo = @min(before.anchor, before.head); const hi = @max(before.anchor, before.head); var graphemes: usize = 0; @@ -12566,11 +9146,11 @@ pub const Pardes = struct { 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) + const mapped: modal.Selection = 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); + pane.setRange(new, eb.row0, mapped, pane.vsel.explicit); } /// `R`: replace the selection (or the cursor char) with the DEFAULT @@ -12620,9 +9200,6 @@ pub const Pardes = struct { /// `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| { @@ -12658,20 +9235,15 @@ pub const Pardes = struct { 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); + const range = pane.primaryRange(text, eb.row0); + const span = Pane.rangeLineSpan(text, range); + const nlines = modal.cursorLineCount(text); var end = span.end; if (span.start == end) end = @min(end + 1, nlines - 1); if (end <= span.start) return; @@ -12680,26 +9252,26 @@ pub const Pardes = struct { var chs_len: usize = 0; var l = span.start; while (l < end) : (l += 1) { - const from = modal.hxLineEndIdx(text, l); + const from = modal.lineEndOffset(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 " "; + const sep: []const u8 = if (to == modal.lineEndOffset(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 cur_off = modal.offsetAt(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)) }) + modal.offsetAt(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); + const cc = modal.positionAt(new, nc); + const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { @@ -12712,18 +9284,13 @@ pub const Pardes = struct { 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 range = pane.primaryRange(text, eb.row0); + const span = Pane.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; @@ -12733,7 +9300,7 @@ pub const Pardes = struct { var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); - const le = modal.hxLineEndIdx(text, l); + const le = modal.lineEndOffset(text, l); const line = text[ls..le]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; // blank lines stay blank (helix) @@ -12762,17 +9329,17 @@ pub const Pardes = struct { } 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 cur_off = modal.offsetAt(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)) }) + modal.offsetAt(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); + const cc = modal.positionAt(new, nc); + const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { @@ -12785,19 +9352,6 @@ pub const Pardes = struct { 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 @@ -12807,19 +9361,13 @@ pub const Pardes = struct { 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 surface = p.flatSurface(pane) catch return; + const last_row = @as(i32, @intCast(panes.File.textLineCount(pane, surface))) - 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); + var rs: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(text, eb.row0, &rs); + const nlines = modal.cursorLineCount(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) @@ -12827,7 +9375,7 @@ pub const Pardes = struct { var lines_len: usize = 0; var next: usize = 0; for (rs[0..got.n]) |r| { - const span = rangeLineSpan(text, r); + const span = Pane.rangeLineSpan(text, r); var l = @max(span.start, next); const end = @min(span.end + 1, nlines); while (l < end) : (l += 1) { @@ -12836,10 +9384,6 @@ pub const Pardes = struct { } 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| { @@ -12853,7 +9397,7 @@ pub const Pardes = struct { 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 line = text[modal.lineStartOffset(text, l)..modal.lineEndOffset(text, l)]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; indent = @min(indent, nw); @@ -12867,7 +9411,7 @@ pub const Pardes = struct { var chs_len: usize = 0; for (lines[0..lines_len]) |l| { const ls = modal.lineStartOffset(text, l); - const le = modal.hxLineEndIdx(text, l); + const le = modal.lineEndOffset(text, l); const line = text[ls..le]; if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched const at = ls + indent; @@ -12882,15 +9426,15 @@ pub const Pardes = struct { 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; + var cells: [Pane.max_selections]Pane.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, .{ + const c = Pane.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); + const cc = modal.positionAt(new, c.cur); + const ac = modal.positionAt(new, c.anc); cells[i] = .{ .row = @as(i32, @intCast(cc.row)) + eb.row0, .col = @intCast(cc.col), @@ -12899,28 +9443,23 @@ pub const Pardes = struct { }; } 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); + const t2 = p.flatSurface(pane) catch return; + for (cells[0..got.n], 0..) |s, i| rs[i] = Pane.cellRange(t2, s.arow, s.acol, s.row, s.col); + pane.setRanges(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; + const rep = (modal.incrementDecimal(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)); + const cc = modal.positionAt(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; @@ -12964,43 +9503,39 @@ pub const Pardes = struct { }; } - /// `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); + fn textobjectSelect(p: *Pardes, pane: *Pane, text: []const u8, obj: u21, around: bool) void { + const range = pane.primaryRange(text, 0); switch (obj) { 'w', 'W' => { - const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); - return setPaneRange(pane, pl, text, r, false); + const r = modal.selectWord(text, range, around, obj == 'W'); + return pane.setRange(text, 0, r, false); }, 'p' => { - const r = modal.hxTextobjectParagraph(text, range, around, 1); - return setPaneRange(pane, pl, text, r, false); + const r = modal.selectParagraph(text, range, around, 1); + return pane.setRange(text, 0, r, false); }, else => {}, } - const cur = modal.hxPos(text, modal.hxCursor(text, range)); + const lines = p.paneCursorLines(pane) catch return; + const cur = modal.positionAt(text, modal.selectionCursor(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, '<', '>'), + '\'', '"', '`' => modal.enclosingQuote(lines, cur, @intCast(obj)), + '(', ')' => modal.enclosingPair(lines, cur, '(', ')'), + '[', ']' => modal.enclosingPair(lines, cur, '[', ']'), + '{', '}' => modal.enclosingPair(lines, cur, '{', '}'), + '<', '>' => modal.enclosingPair(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)); + var a = modal.offsetAt(text, pr.a); + var head = modal.nextGrapheme(text, modal.offsetAt(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); + pane.setRange(text, 0, .{ .anchor = a, .head = head }, false); } /// `ms`: wrap the selection (or the cursor char) in a pair; the wrap @@ -13026,27 +9561,25 @@ pub const Pardes = struct { } /// `md`: delete the enclosing pair's chars; the cursor maps through - fn surroundDelete(p: *Pardes, pane: *Pane, pl: PaneLines, ch: u21) void { + fn surroundDelete(p: *Pardes, pane: *Pane, 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 lines = p.paneCursorLines(pane) catch return; + const cur = pane.toModalCursor(); const r = (if (pr.o == pr.c) - modal.enclosingQuote(pl.lines, cur, pr.o) + modal.enclosingQuote(lines, cur, pr.o) else - modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return; + modal.enclosingPair(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 eb = p.editText(pane, lo, hi, -1) orelse return; const text = eb.text; - const drow = pl.row0 - eb.row0; // surface row -> buffer row + const drow = -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 }); + const a_off = modal.offsetAt(text, ra); + const b_off = modal.offsetAt(text, rb); + const cur_off = modal.offsetAt(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); @@ -13055,7 +9588,7 @@ pub const Pardes = struct { var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; - const cc = modal.hxPos(new, nc); + const cc = modal.positionAt(new, nc); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.vsel.active = false; @@ -13065,16 +9598,17 @@ pub const Pardes = struct { } /// `mr`: swap the enclosing pair's chars for 's - fn surroundReplace(p: *Pardes, pane: *Pane, pl: PaneLines, from: u21, to: u21) void { + fn surroundReplace(p: *Pardes, pane: *Pane, from: u21, to: u21) void { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; - const cur = toModalCursor(pane, pl); + const lines = p.paneCursorLines(pane) catch return; + const cur = pane.toModalCursor(); const r = (if (fp.o == fp.c) - modal.enclosingQuote(pl.lines, cur, fp.o) + modal.enclosingQuote(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 + modal.enclosingPair(lines, cur, fp.o, fp.c)) orelse return; + const eb = p.editText(pane, @as(i32, @intCast(r.a.row)), @as(i32, @intCast(r.b.row)), -1) orelse return; + const drow = -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); @@ -13086,57 +9620,25 @@ pub const Pardes = struct { // ---- 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); + if (pane.file != null) return panes.File.pushUndo(p, pane); + panes.Terminal.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); + if (pane.file != null) return panes.File.undo(p, pane); + panes.Terminal.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); + if (pane.file != null) return panes.File.redo(p, pane); + panes.Terminal.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| { @@ -13153,7 +9655,7 @@ pub const Pardes = struct { 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 rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (row == handle) return .{ .col = col, .row = handle }; } @@ -13172,7 +9674,7 @@ pub const Pardes = struct { 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 rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle }; } @@ -13190,14 +9692,10 @@ pub const Pardes = struct { } const PointerTextSelection = struct { - sel: Sel, + sel: Pane.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]; @@ -13233,9 +9731,6 @@ pub const Pardes = struct { 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; @@ -13254,14 +9749,9 @@ pub const Pardes = struct { 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 + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse return p.cancelLookHover(); p.hover_col = mapped.col; p.hover_row = mapped.row; @@ -13277,8 +9767,8 @@ pub const Pardes = struct { 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 + if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { + const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); const ready = waiting.*; p.look_hover_wait = null; @@ -13307,190 +9797,10 @@ pub const Pardes = struct { 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. + pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void { + p.presentation.acknowledge(p, tracks); if (p.pointer_inside and p.drag != .none) { - if (p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row)) |mapped| { + if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| { p.hover_col = mapped.col; p.hover_row = mapped.row; p.dragUpdate(mapped.col, mapped.row); @@ -13499,57 +9809,8 @@ pub const Pardes = struct { 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.presentation.cancel(); p.cancelLookHover(); } @@ -13571,11 +9832,8 @@ pub const Pardes = struct { return; } p.raw_hover_intent = false; - const mapped = p.presentedPointer(raw_col, raw_row) orelse { + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, 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; }; @@ -13596,9 +9854,6 @@ pub const Pardes = struct { 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) { @@ -13610,41 +9865,33 @@ pub const Pardes = struct { _ = 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) + if (pane.hasPdf()) + panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) else pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; - // 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); + if (pane.hasPdf()) { + panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back } else if (pane.file != null and !p.settings.wrap) { // wrapped there is nothing off to the right to reach - const line = file_pane.sourceLine(pane, pane.cur_row); - const visual = file_pane.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); + const line = panes.File.sourceLine(pane, pane.cur_row); + const visual = panes.File.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); const next: usize = if (m.button == .wheel_right) visual +| @as(usize, @intCast(config.wheel_cols)) else visual -| @as(usize, @intCast(config.wheel_cols)); - pane.hscroll = @intCast(file_pane.rawAtDisplay(line, next)); + pane.hscroll = @intCast(panes.File.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); @@ -13658,15 +9905,12 @@ pub const Pardes = struct { 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); + panes.Pdf.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; @@ -13675,17 +9919,6 @@ pub const Pardes = struct { 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| @@ -13708,11 +9941,6 @@ pub const Pardes = struct { 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) { @@ -13720,10 +9948,6 @@ pub const Pardes = struct { 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; @@ -13734,15 +9958,11 @@ pub const Pardes = struct { 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( + .pdf = panes.Pdf.pointerStart( p, pane, config.select_button, @@ -13771,7 +9991,7 @@ pub const Pardes = struct { 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); + panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; @@ -13782,15 +10002,7 @@ pub const Pardes = struct { 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 word = wordAtCol(bar, panes.File.rawAtDisplay(bar, mcol)); const named = std.meta.stringToEnum(Builtin, word); const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) @@ -13809,17 +10021,11 @@ pub const Pardes = struct { 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 = .{ @@ -13827,7 +10033,7 @@ pub const Pardes = struct { .button = m.button, // A native body gesture stays native even when its // point later misses a letterbox or selection. - .pdf = pdf_pane.pointerStart( + .pdf = panes.Pdf.pointerStart( p, pane, m.button, @@ -13862,14 +10068,9 @@ pub const Pardes = struct { 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]]; + const a = p.rects[p.col_panes[k.col][k.idx]]; + const b = p.rects[p.col_panes[k.col][k.idx + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else { d.cur_y = mrow; @@ -13879,8 +10080,8 @@ pub const Pardes = struct { 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]]; + const a = p.rects[p.col_panes[cc][k]]; + const b = p.rects[p.col_panes[cc][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else d.cur_y = mrow; }, @@ -13893,20 +10094,6 @@ pub const Pardes = struct { 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) @@ -13915,33 +10102,20 @@ pub const Pardes = struct { @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); + panes.Pdf.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_col = @intCast(@min(text.len, panes.File.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, @@ -13953,46 +10127,27 @@ pub const Pardes = struct { ) bool { if (!p.fs.scripted(id)) return false; const is_look = cmd == config.look_cmd; - const action: acmefs.Action = if (is_look) + const action: filesystem.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; + var flag: u32 = if (named) filesystem.flag_builtin else 0; + if (range.q0 == range.q1 and text.len > 0) flag |= filesystem.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 { + if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= filesystem.flag_filename; + } else if (chorded) flag |= filesystem.flag_chorded; + return filesystem.noteAction(p, id, action, range.q0, range.q1, flag, text); + } + + fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) filesystem.PaneState.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 at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead))); const q0: u32 = @intCast(@min(at, tag.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } @@ -14005,14 +10160,14 @@ pub const Pardes = struct { 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); + const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane)); break :lead .{ .row = @intCast(@max(0, w.line)), .col = @intCast(@max(0, col)), }; } else null; const cursor = start orelse return .{}; - const q0: u32 = @intCast(@min(modal.hxOff(f.content, cursor), f.content.len)); + const q0: u32 = @intCast(@min(modal.offsetAt(f.content, cursor), f.content.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) }; } @@ -14027,10 +10182,6 @@ pub const Pardes = struct { 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; }; @@ -14061,22 +10212,18 @@ pub const Pardes = struct { 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); + layout.applyRowSplit(p, 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), + layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), .move => |d| { - p.moveTerm(d.id, d.cur_x, d.cur_y); + layout.movePane(p, 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) + if (layout.findPane(p, d.id)) |f| if (f.col == 0) p.evictLonePristineTty(0, d.id); }; }, @@ -14084,7 +10231,7 @@ pub const Pardes = struct { const pane = p.panes[s.id] orelse return; if (comptime pdf_enabled) { if (s.pdf.native) { - var release = pdf_pane.pointerRelease( + var release = panes.Pdf.pointerRelease( p, pane, s.pdf, @@ -14111,60 +10258,34 @@ pub const Pardes = struct { 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), + sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) 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.normal.clear(); 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; @@ -14182,38 +10303,6 @@ pub const Pardes = struct { } } - /// 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; @@ -14228,13 +10317,6 @@ pub const Pardes = struct { 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 @@ -14242,14 +10324,11 @@ pub const Pardes = struct { 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) + if (panes.Terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999)); var mb: [32]u8 = undefined; - if (term_pane.mouseFormatSgr(pane)) { + if (panes.Terminal.mouseFormatSgr(pane)) { p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return); } else { // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them @@ -14265,7 +10344,7 @@ pub const Pardes = struct { 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; + const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; // both ends of the sweep through the same last-frame map, so a // chord over wrapped rows cuts the text that was under it const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H))); @@ -14278,7 +10357,7 @@ pub const Pardes = struct { pane.cur_col = col1; pane.cur_pinned = true; pane.msel.active = false; - pane.pending = 0; + pane.normal.clear(); if (!pane.isTerminal()) pane.mode = .normal; pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; pane.nsel = 0; @@ -14295,7 +10374,7 @@ pub const Pardes = struct { 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); + panes.File.setContent(p, f, new); pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { @@ -14309,45 +10388,7 @@ pub const Pardes = struct { 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; - } + } else p.pasteText(pane, p.yank orelse return, true, 1); } /// Newtty: a shell in the caller's directory, raw from the first frame, @@ -14358,188 +10399,20 @@ pub const Pardes = struct { 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); + const parent = layout.splitParent(p, from); + const f = layout.findPane(p, parent).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, parent, nt); p.active = free; } - /// 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]; + const cid = p.col_panes[col][k]; if (p.panes[cid]) |ct| if (ct.isTerminal()) { n_tty += 1; tty_id = cid; @@ -14549,27 +10422,16 @@ pub const Pardes = struct { 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); + if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or + panes.Terminal.scrollbar(tt).total > tt.rows) return; + p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); + layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows + layout.absorbVWeight(p, tty_id); + layout.removePane(p, tty_id); 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; @@ -14580,11 +10442,11 @@ pub const Pardes = struct { 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); + const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); + const f = layout.findPane(p, src).?; + layout.splitBelow(p, src, nt); + layout.insert(p, f.col, f.idx + 1, free); + } else layout.insert(p, rc, p.col_n[rc], free); return free; } @@ -14595,25 +10457,27 @@ pub const Pardes = struct { errdefer p.gpa.free(content); const path = try p.gpa.dupe(u8, "/Tutor"); errdefer p.gpa.free(path); + const history = try panes.File.History.create(p.gpa); + errdefer p.gpa.destroy(history); const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content }; + pane.file = .{ .path = path, .content = content, .history = history }; 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 { + pub fn setTestFile(p: *Pardes, content: []const u8) !*Pane { const copy = try p.gpa.dupe(u8, content); errdefer p.gpa.free(copy); - const path = try p.gpa.dupe(u8, "/hxcase.txt"); + const path = try p.gpa.dupe(u8, "/test.txt"); errdefer p.gpa.free(path); - p.deinitPane(p.panes[0].?); + const history = try panes.File.History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + errdefer p.gpa.destroy(pane); + try p.deinitPane(p.panes[0].?); p.panes[0] = null; - const pane = try p.newDocPane(0); - pane.file = .{ .path = path, .content = copy }; + pane.file = .{ .path = path, .content = copy, .history = history }; + p.installPane(0, pane); pane.cur_pinned = true; p.active = 0; p.sync(); @@ -14623,40 +10487,29 @@ pub const Pardes = struct { 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.normal.clear(); 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); + if (pane.normal.prefix == .none and isPrefix(key, 'f')) { + pane.normal.clear(); return p.runBuiltin(.PdfSections, p.active, "", null); } - const parsed = normal_input.parse(&state, normalInput(key)); - putPaneNormalState(pane, state); + const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); switch (parsed) { .pending, .ignored, .unbound => {}, .action => |semantic| { - const result = pdf_pane.applyNormal( + const result = panes.Pdf.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), + panes.Pdf.paneViewport(p, pane), + panes.Pdf.paneGeometry(p, pane), ); - if (result.page_changed) pdf_pane.resetPageChrome(pane); + if (result.page_changed) panes.Pdf.resetPageChrome(pane); switch (result.host) { .none => {}, .leader => { @@ -14667,7 +10520,7 @@ pub const Pardes = struct { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, - .search => p.startSearch(pane, config.search_marker), + .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => p.lookWalk(1), .search_backward => p.lookWalk(-1), } @@ -14675,71 +10528,37 @@ pub const Pardes = struct { } } - /// 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; + if (p.col_n[lc] > 0) if (p.panes[p.col_panes[lc][0]]) |top| { + layout.snapColWeights(p, lc); + const h = p.rects[p.col_panes[lc][0]].h; nt.vweight = @floatFromInt(@max(1, h / 2)); top.vweight = @floatFromInt(@max(1, h -| h / 2)); }; - p.layoutInsert(lc, 0, free); + layout.insert(p, 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). `landing` says how far - /// the view may MOVE to show it: `.center` recenters a file on the line and - /// reveals a PDF's page — a look target, a `:NN`, a search hit, where the - /// context around the destination is the whole point of going there — while - /// `.keep` leaves the view alone and lets `ensureCursorVisible` do the least - /// that shows the cursor, usually nothing at all. A terminal has no recenter - /// to skip, so `landing` does not gate it — but it is not therefore free of - /// movement: a modal cursor PINNED high in the scrollback still pulls the - /// view up to it, which is `ensureCursorVisible` keeping its promise and the - /// reason `Last` records `line = 0` for an unpinned shell. 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, landing: enum { center, keep }) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; - if (hasPdf(pane)) { - // A page reveal IS this pane's view, so `.keep` is simply not doing - // it. Not a formality: a reveal of the page you are already on still - // sets `document_scroll_y` to `page_starts[page]`, so returning to a - // PDF threw away the offset WITHIN the page you were reading. - // - // Read that literally — under `.keep` a PDF's page is not restored - // AT ALL, and the spot's line is a page. That only shows when - // something moved the pane while you were away, and something can: - // the wheel scrolls the pane under the POINTER, not the active one. - // Then Esc leaves the PDF on the page the wheel reached rather than - // the one the jumplist recorded, which is the answer a RETURN wants - // and not the answer a jump wants — so Ctrl-o and Ctrl-i, which - // centre, are still how you reach the recorded page. + pane.normal.clear(); + if (pane.hasPdf()) { if (landing == .keep) return; if (comptime pdf_enabled) { const pv = &pane.pdf.?; - if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane); + if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane); } return; } if (at.line == 0) return; if (pane.file) |*f| { - // The clamp holds either way: a stale jump naming a line past the - // end must not land the cursor there just because the view is not - // moving. - if (at.line > file_pane.nlines(p.gpa, f)) return; + if (at.line > panes.File.nlines(p.gpa, f)) return; if (landing == .center) { const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { @@ -14748,11 +10567,6 @@ pub const Pardes = struct { } } } - // Land the modal cursor on the target line. Under `.center` that keeps - // ensureCursorVisible agreeing with the recenter — a stale cursor would - // yank the view right back. Under `.keep` it IS the whole policy: no - // recenter ran, so the nudge below is the only thing that can move the - // view, and it moves it only far enough to show the cursor. pane.cur_row = @intCast(at.line - 1); pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0; pane.cur_pinned = true; @@ -14760,36 +10574,16 @@ pub const Pardes = struct { 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; + const text = p.flatSurface(pane) catch return; // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a // 1-based zero is the same nothing an absent number is const acol: i32 = @intCast(at.col -| 1); const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32); - const r = cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); - setPaneRange(pane, pl, text, r, true); + const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); + pane.setRange(text, 0, 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; @@ -14809,50 +10603,19 @@ pub const Pardes = struct { 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 (comptime pdf_enabled) if (panes.Pdf.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; @@ -14870,51 +10633,27 @@ pub const Pardes = struct { 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); + found = look.resolve(p, 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, .center), .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; @@ -14922,26 +10661,20 @@ pub const Pardes = struct { return; }; } - // A LOOK WITH NOWHERE TO PUT THE ANSWER SAYS SO. All four - // slot checks in this function were a bare `orelse return`, - // which with one selection merely felt like a dead key and with - // several means "I opened nine of your fourteen and told you - // nothing". `NoPaneSlots` is the error output_pane.zig already - // raises for this, through the channel a test already pins. const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); nt.greet = true; - const src = p.splitParent(id); - const f = p.layoutFindTerm(src).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, id); + const f = layout.findPane(p, src).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = pdf_pane.openPane(p, free, target.path, target.at.line) catch |err| + const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); return; @@ -14949,22 +10682,12 @@ pub const Pardes = struct { // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - // ...and WHY a file would not open, which `look.readFile` now - // distinguishes: permission denied, a pipe, too large. - const nt = file_pane.open(p, free, target.path, target.at.line) catch |err| + const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); p.placeDoc(id, free, nt); - // 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(); + layout.compute(p); 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); @@ -14974,52 +10697,27 @@ pub const Pardes = struct { .image => |target| { if (p.focusPaneByPath(target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = image_pane.create(p, free, target.path, &.{}) catch |err| + const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); 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| { + panes.Terminal.padOutputBelowEdits(p, dst); + if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { p.reportError(id, "queue command", err); return dst; }) return dst; @@ -15036,20 +10734,9 @@ pub const Pardes = struct { 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; @@ -15068,17 +10755,6 @@ pub const Pardes = struct { 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; @@ -15086,10 +10762,8 @@ pub const Pardes = struct { 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 { + pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { + const previous_colors = p.settings.colors; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; switch (setting.action) { @@ -15105,109 +10779,66 @@ pub const Pardes = struct { 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; + if (!p.settings.requestFont(matches[0].path, matches[0].name)) return; p.font_request_taken = false; }, - else => _ = runtime_cfg.applySimple(&p.settings, setting, arg), + else => _ = p.settings.apply(setting, arg), + } + if (p.settings.colors != previous_colors) { + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file) |*file| file.syntax_dirty = true; + } } 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 + const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) { + if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { src_id = from_id; }; - // 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)) { + if (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf()) { 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))) { + if (sl) |pp| if (i != free and (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf())) { src_id = i; break; }; }; - // 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 + if (!doc) if (layout.findPane(p, from_id)) |sf| { + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; if (src_id) |sid| { - const src = p.splitParent(sid); - const sf = p.layoutFindTerm(src).?; - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, sid); + const sf = layout.findPane(p, src).?; + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, 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); + if (doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100)) { + if (!layout.splitColumn(p, from_id, free, true)) return p.stackDocLeft(free, nt); p.active = free; return; } @@ -15219,7 +10850,7 @@ pub const Pardes = struct { 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 dump_panes: [MAX_PANES]dump.Pane = undefined; var panes_len: usize = 0; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; @@ -15227,32 +10858,25 @@ pub const Pardes = struct { 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( + var dp: dump.Pane = if (pane.file) |*f| try panes.File.dumpPane( arena, pane, f, tag, body, scroll, - if (f.output) |o| output_pane.word(o.from) else "", + if (f.output) |o| panes.Output.word(o.from) else "", if (f.output) |*o| o.arg() else "", - ) else if (hasPdf(pane)) blk: { + ) else if (pane.hasPdf()) 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, &.{}); + break :blk try panes.Image.dumpPane(p, 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) + try panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else - try term_pane.dumpPane(pane, arena, tag, body, scroll); + try panes.Terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null; - panes[panes_len] = dp; + dump_panes[panes_len] = dp; panes_len += 1; } @@ -15261,7 +10885,7 @@ pub const Pardes = struct { 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| { + for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_panes[c][k]]) |compact| { column_ids[c][ids_len] = compact; ids_len += 1; }; @@ -15274,13 +10898,16 @@ pub const Pardes = struct { } } + var mounts: [filesystem.max_mounts]dump.Mount = undefined; + for (p.fs.mounts.items, 0..) |mount, i| mounts[i] = .{ .name = mount.name, .dial = mount.dial }; const state: dump.State = .{ .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, .theme = p.theme().name, .columns = columns[0..columns_len], - .panes = panes[0..panes_len], + .panes = dump_panes[0..panes_len], + .mounts = mounts[0..p.fs.mounts.items.len], }; try dump.validate(state); var out: std.Io.Writer.Allocating = .init(p.gpa); @@ -15291,10 +10918,18 @@ pub const Pardes = struct { 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 restore(p: *Pardes, zon_bytes: []const u8) !*Pardes { + var opts = p.opts; + opts.cols = p.screen_w; + opts.rows = p.screen_h; + return initDump(p.gpa, opts, zon_bytes, p); + } + pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { + return initDump(gpa, opts, zon_bytes, null); + } + + fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; @@ -15305,6 +10940,11 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .fs = .{ + .socket_path = opts.ninep_identity.socket_path, + .tcp_address = opts.ninep_identity.tcp_address, + .quic_address = opts.ninep_identity.quic_address, + }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -15312,8 +10952,32 @@ pub const Pardes = struct { .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); - const st = try dump.readZon(gpa, zon_bytes, "load"); - defer dump.free(gpa, st); + if (previous) |old| { + p.next_serial = old.next_serial; + p.lsp_seq = old.lsp_seq; + p.pipe_seq = old.pipe_seq; + p.cell_pixels = old.cell_pixels; + p.native_images = old.native_images; + p.fs.socket_path = old.fs.socket_path; + p.fs.tcp_address = old.fs.tcp_address; + p.fs.quic_address = old.fs.quic_address; + } + for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); + p.opts.mounts = &.{}; + p.opts.ninep_identity = .{}; + var parsed = try dump.readZon(gpa, zon_bytes, "load"); + defer parsed.deinit(); + const st = parsed.value; + for (st.mounts) |mount| { + var already_mounted = false; + for (p.fs.mounts.items) |existing| { + if (!std.mem.eql(u8, mount.name, existing.name)) continue; + if (!std.mem.eql(u8, mount.dial, existing.dial)) return error.MountConflict; + already_mounted = true; + break; + } + if (!already_mounted) try p.fs.mount(gpa, mount.name, mount.dial); + } for (themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i); @@ -15323,23 +10987,23 @@ pub const Pardes = struct { const pane: *Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; - const restored = try term_pane.restore(p, src); + const restored = try panes.Terminal.restore(p, src); p.installPane(i, restored); - p.setCwd(i, t.cwd); + try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; break :terminal restored; }, - .file => try file_pane.restore(p, i, src), + .file => try panes.File.restore(p, i, src), .image => restore_image: { const im = src.image.?; - if (pdf_pane.isPath(im.path)) { + if (panes.Pdf.isPath(im.path)) { var raw: []u8 = if (im.bytes_b64.len > 0) try dump.decodeBytes(gpa, im.bytes_b64) else &.{}; errdefer if (raw.len > 0) gpa.free(raw); if (comptime pdf_enabled) { - if (pdf_pane.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { + if (panes.Pdf.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { if (raw.len > 0) gpa.free(raw); raw = &.{}; restored.cols = @max(1, src.cols); @@ -15354,15 +11018,17 @@ pub const Pardes = struct { const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); raw = &.{}; errdefer if (content.len > 0) gpa.free(content); + const history = try panes.File.History.create(gpa); + errdefer gpa.destroy(history); const restored = try p.newDocPane(i); - restored.file = .{ .path = path, .content = content }; + restored.file = .{ .path = path, .content = content, .history = history }; restored.cur_pinned = true; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); - p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); + p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true, .mode = .baseline_disk } }); break :restore_image restored; } - break :restore_image try image_pane.restore(p, i, src); + break :restore_image try panes.Image.restore(p, i, src); }, }; p.restoreDumpTail(pane, src); @@ -15374,14 +11040,14 @@ pub const Pardes = struct { 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; + for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_panes[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); - p.panel_animation_enabled = true; + p.presentation.enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture return p; } @@ -15393,12 +11059,6 @@ pub const Pardes = struct { 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)) @@ -15409,25 +11069,18 @@ pub const Pardes = struct { 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| + if (pane.image) |*state| if (panes.Image.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| + if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.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 { @@ -15436,11 +11089,6 @@ pub const Pardes = struct { 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; @@ -15459,22 +11107,10 @@ pub const Pardes = struct { ) 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, @@ -15487,11 +11123,11 @@ pub const Pardes = struct { pane.tag_init = true; } - fn removePane(p: *Pardes, id: usize) void { + pub fn removePane(p: *Pardes, id: usize) !void { const pane = p.panes[id] orelse return; - p.absorbVWeight(id); - p.layoutRemove(id); - p.deinitPane(pane); + try p.deinitPane(pane); + layout.absorbVWeight(p, id); + layout.removePane(p, id); p.panes[id] = null; if (p.active == id) p.active = p.prevFocus(id) orelse { p.quit = true; @@ -15500,10 +11136,28 @@ pub const Pardes = struct { }; } - /// 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 removeColumn(p: *Pardes, id: usize) !void { + const place = layout.findPane(p, id) orelse return; + var ids: [MAX_PANES]usize = undefined; + var parents: [MAX_PANES]*Pane = undefined; + const count = p.col_n[place.col]; + for (0..count) |i| { + ids[i] = p.col_panes[place.col][i]; + parents[i] = p.panes[ids[i]].?; + } + try p.detachCwds(parents[0..count]); + for (ids[0..count], parents[0..count]) |closed, pane| { + p.retirePane(pane); + layout.removePane(p, closed); + p.panes[closed] = null; + } + if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { + p.quit = true; + p.emit(.quit); + return; + }; + } + pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.njumps; while (i > 0) { @@ -15517,27 +11171,6 @@ pub const Pardes = struct { 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; @@ -15546,9 +11179,6 @@ pub const Pardes = struct { } 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| { @@ -15562,13 +11192,6 @@ pub const Pardes = struct { 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, @@ -15582,22 +11205,6 @@ pub const Pardes = struct { // 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; @@ -15613,18 +11220,6 @@ pub const Pardes = struct { 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. - /// - /// `.center` where `Last` keeps the view, and the asymmetry is structural - /// rather than arbitrary: `Last` only ever CROSSES panes, so the pane it - /// lands on already holds the view you left it with. This may land in the - /// SAME pane, where there is no such view to keep — a long in-file jump - /// would arrive on the very top or bottom row with `scroll_off` lines of - /// context on one side. Helix splits the same pair the same way: its - /// jumplist centres, its buffer switch does not. 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; @@ -15637,42 +11232,37 @@ pub const Pardes = struct { /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { p.reapPanes(); - p.computeGeom(); - p.syncPanelAnimations(); + layout.compute(p); + p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; - if (comptime terminal_panes) if (pane.reply_len > 0) { + if (comptime terminal_panes) if (pane.terminal) |state| { 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]) } }); + while (off < state.reply_len) { + const n = @min(state.reply_len - off, 64); + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(state.reply[off .. off + n]) } }); off += n; } - pane.reply_len = 0; + state.reply_len = 0; }; const r = p.rects[id]; const cols = @max(1, r.w -| config.GUTTER); const rows = @max(1, r.h -| BOX_H); // the tag steals the top row - // 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); + panes.Terminal.resizeGrid(pane, p.gpa, cols, rows); p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); } pane.cols = cols; pane.rows = rows; + if (pane.file) |*file| file.syntax_dirty = true; } - 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)) { + panes.Terminal.releasePendingCommandIfReady(p, id, pane); + if (pane.greet and pane.isTerminal() and p.resize_count > 0 and panes.Terminal.promptInputReady(pane)) { p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } }); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } }); pane.greet = false; @@ -15680,282 +11270,8 @@ pub const Pardes = struct { } } - 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.?; + if (p.custom_theme) |*custom| return custom; return &themes[p.settings.theme]; } @@ -15971,15 +11287,9 @@ pub const Pardes = struct { 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; + return p.presentation.animating(); } - /// 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; @@ -16007,13 +11317,9 @@ pub const Pardes = struct { }; } - /// 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 + (p.custom_theme == null 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) @@ -16022,7 +11328,6 @@ pub const Pardes = struct { 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) { @@ -16037,12 +11342,13 @@ pub const Pardes = struct { // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { - file_pane.refreshHighlights(p); + panes.File.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { + const cells = try p.gpa.alloc(Cell, ncells); p.gpa.free(s.cells); - s.cells = try p.gpa.alloc(Cell, ncells); + s.cells = cells; } s.cols = p.screen_w; s.rows = p.screen_h; @@ -16060,43 +11366,10 @@ pub const Pardes = struct { 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)..] @@ -16108,34 +11381,13 @@ pub const Pardes = struct { 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"); @@ -16147,17 +11399,12 @@ pub const Pardes = struct { 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)]); + const prompt_col = panes.File.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 }; } @@ -16166,9 +11413,6 @@ pub const Pardes = struct { // 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; @@ -16177,16 +11421,11 @@ pub const Pardes = struct { .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); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo); + const hi = panes.File.rawDisplayCol(bar, bounds.hi); while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), 0); cell.default = false; @@ -16195,32 +11434,17 @@ pub const Pardes = struct { } } } - // 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); + const col = panes.File.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; @@ -16232,7 +11456,7 @@ pub const Pardes = struct { 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| { + if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); @@ -16257,12 +11481,6 @@ pub const Pardes = struct { 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; @@ -16283,19 +11501,15 @@ pub const Pardes = struct { var sb_total: usize = undefined; if (at.file) |*f| { sb_off = f.scroll; - sb_total = file_pane.nlines(p.gpa, f); + sb_total = panes.File.nlines(p.gpa, f); } else if (at.pdfPage()) |page| { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { - const sb = term_pane.scrollbar(at); + const sb = panes.Terminal.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; @@ -16334,60 +11548,17 @@ pub const Pardes = struct { } } }; - 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. The rule is `panel_animation.paintOrder` so that it has exactly - // one definition: every host receives this same deterministic dense - // record set and none of them needs to sort it again. - s.npanel_tracks = panel_animation.paintOrder( - &p.panel_tracks, - p.closing_panel_tracks[0..p.nclosing_panel_tracks], - &s.panel_tracks, - ); + try p.presentation.submit(p, s); 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 area = layout.CellArea.of(track.to); const col_end = @min(canonical.cols, area.x0 +| area.cols); const row_end = @min(canonical.rows, area.y0 +| area.rows); var row: u16 = area.y0; @@ -16411,15 +11582,10 @@ pub const Pardes = struct { 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, + track: layout.Track, + area: layout.CellArea, col: u16, row: u16, index: usize, @@ -16431,13 +11597,10 @@ pub const Pardes = struct { }; 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)) { + return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) { .old => canonical.previous_cells[index], .byte => |byte| withByte(canonical.cells[index], byte), // Churn belongs on the glyph, not on the pane's empty space, and it @@ -16480,50 +11643,6 @@ pub const Pardes = struct { 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, @@ -16532,7 +11651,7 @@ pub const Pardes = struct { tw: u16, tag_y: u16, body_y: u16, - sl: Sel, + sl: Pane.Sel, bg: [3]u8, fg: ?[3]u8, ) void { @@ -16543,13 +11662,10 @@ pub const Pardes = struct { 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 + // File line numbers occupy a gutter 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; + var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else c0; while (col <= c1 and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), sy); cell.default = false; @@ -16568,21 +11684,9 @@ pub const Pardes = struct { 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); @@ -16595,31 +11699,6 @@ pub const Pardes = struct { 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 }; @@ -16647,44 +11726,23 @@ pub const Pardes = struct { .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); + var col = panes.File.rawDisplayCol(tag, b.lo); const hi = modal.nextGrapheme(tag, b.hi); - const end = file_pane.rawDisplayCol(tag, hi) -| 1; + const end = panes.File.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; @@ -16695,21 +11753,15 @@ pub const Pardes = struct { // 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); + const col = panes.File.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; + if (pane.hasPdf() and panes.Pdf.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); + panes.Image.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 } }); @@ -16739,55 +11791,21 @@ pub const Pardes = struct { } } - // 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. const tz_color = tracy.zone(@src(), "paneRecolor"); switch (pane.colorAlgo()) { - // Every mode, not just `.tty`: `recolorAnsi` translates a row's - // colour anchor through the same slide the edit buffer applied to - // its text, so leaving a shell for normal mode no longer drains - // the screen of colour. A row the user typed has no ANSI and is - // skipped there, which is why this needs no mode test. - // `body` is the very text printed above: `recolorAnsi` pairs its - // graphemes with the cells that spelled them, which is the only way - // to stay on the right glyph when the emulator and this surface - // disagree about how many columns a cluster is worth. - .tty => if (p.settings.colors) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, body), + .tty => if (p.settings.colors) panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body), // `.locations` joins them because it feeds the same `f.highlights` // — only the pass that FILLED it differs (refreshHighlights). .source, .diff, .locations => { 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); + panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); + if (p.settings.colors) panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); + panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); }, .none => {}, } tz_color.end(); - // 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)), @@ -16800,7 +11818,7 @@ pub const Pardes = struct { // 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) + panes.File.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); @@ -16826,12 +11844,9 @@ pub const Pardes = struct { 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; + var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, body_y + row - BOX_H); cell.default = false; @@ -16840,29 +11855,17 @@ pub const Pardes = struct { } } } - // 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; + const vpfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) 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{ + const sr = if (primary) Pane.SelRange{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, @@ -16870,27 +11873,12 @@ pub const Pardes = struct { } 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); @@ -16898,16 +11886,16 @@ pub const Pardes = struct { 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) + panes.File.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 + panes.File.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) + panes.File.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 + panes.File.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); @@ -16923,9 +11911,9 @@ pub const Pardes = struct { 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) + panes.File.displayOffset(pane, sr.row, cw.at, sr.col) else - file_pane.lineDisplayOffset( + panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cw.row))), @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), @@ -16942,22 +11930,19 @@ pub const Pardes = struct { // 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 cur = panes.Terminal.gridCursor(pane); + const goff = panes.Terminal.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) + @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, crow, cwp.at, ccol) else if (pane.cur_pinned) - file_pane.lineDisplayOffset( + panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cwp.row))), @intCast(@max(0, cwp.at)), @intCast(@max(0, ccol)), @@ -16966,35 +11951,17 @@ pub const Pardes = struct { 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 }; + } else if (panes.Terminal.visibleCursor(pane)) |cur| { + if (cur.y + BOX_H < r.h and cur.x < tw) + s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } } - // 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), + .total = panes.File.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else if (pane.pdfPage()) |page| .{ @@ -17002,7 +11969,7 @@ pub const Pardes = struct { .offset = page, .len = 1, } else blk: { - const gsb = term_pane.scrollbar(pane); + const gsb = panes.Terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = r.h - BOX_H; @@ -17033,20 +12000,14 @@ pub const Pardes = struct { 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( + if (pane.hasPdf()) { + if (comptime pdf_enabled) return panes.Pdf.visibleText( &pane.pdf.?, p.pdf_gpa, arena, @@ -17054,17 +12015,14 @@ pub const Pardes = struct { ); unreachable; } - if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.settings.wrap); - return term_pane.bodyText(arena, pane); + if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); + return panes.Terminal.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; @@ -17074,9 +12032,6 @@ test "Esc alternates between two panes of the SAME kind" { 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; @@ -17093,10 +12048,6 @@ test "Esc alternates between two panes of the SAME kind" { test "a boot file that will not open boots an errors pane rather than failing the launch" { if (platform == .web) return; const gpa = std.testing.allocator; - // A path `look.resolve` would have accepted and `readFile` cannot open. - // Spelled as a name that is simply not there rather than by chmod-ing a - // fixture to 000, because the second answers differently when the suite - // runs as root and this must fail the same way everywhere. const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, @@ -17107,7 +12058,7 @@ test "a boot file that will not open boots an errors pane rather than failing th const pane = p.panes[0].?; const f = pane.file.?; - try std.testing.expectEqual(output_pane.Origin.errors, f.output.?.from); + try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // The reason IN WORDS, not an error name: `PermissionDenied` on a screen // is jargon, and the whole point of this pane is that a human reads it. try std.testing.expect(std.mem.indexOf(u8, f.content, "cannot open") != null); @@ -17129,26 +12080,99 @@ test "argv naming nothing boots an errors pane rather than failing the launch" { // shell a bare `pardes` boots: the answer is the whole screen. try std.testing.expectEqual(@as(u8, 1), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); - try std.testing.expectEqual(@as(usize, 0), p.col_terms[0][0]); + try std.testing.expectEqual(@as(usize, 0), p.col_panes[0][0]); try std.testing.expectEqual(@as(usize, 0), p.active); for (p.panes[1..]) |slot| try std.testing.expect(slot == null); const pane = p.panes[0].?; try std.testing.expect(!pane.isTerminal()); const f = pane.file.?; - try std.testing.expectEqual(output_pane.Origin.errors, f.output.?.from); + try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // ...and it says what happened AND what was asked for. The word as typed, // which is the half a bare "not found" leaves out. try std.testing.expect(std.mem.indexOf(u8, f.content, "not found") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "notes/tdoo.md") != null); - // THE PANE'S DIRECTORY IS THE LAUNCH DIRECTORY, asserted through the path - // because that is what `paneDir` reads: it decides where a `Grep` from - // this pane walks, where its `Newtty` spawns, and what its `Save` - // prefills. Passing "" here put the pane at `/+Errors`, i.e. rooted every - // one of those at `/`. try std.testing.expectEqualStrings("/home/pardes-test/work/+Errors", f.path); } +test "raw terminal cursor obeys visibility without hiding modal and tag cursors" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + p.update(.{ .output = .{ .pane = 0, .bytes = "abc" } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const visible = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; + try std.testing.expectEqual(rect.x + config.GUTTER + 3, visible.x); + try std.testing.expectEqual(body_y, visible.y); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + pane.mode = .normal; + pane.cur_pinned = true; + pane.cur_row = 0; + pane.cur_col = 1; + const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); + try std.testing.expectEqual(body_y, modal_cursor.y); + p.enterTagEdit(pane, 0); + const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); + Pardes.exitTagEdit(pane); + pane.mode = .tty; + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25h\x1b[?1049h\x1b[2;5H" } }); + const alternate = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(rect.x + config.GUTTER + 4, alternate.x); + try std.testing.expectEqual(body_y + 1, alternate.y); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l\x1b[?25h" } }); + const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(visible.x, returned.x); + try std.testing.expectEqual(visible.y, returned.y); +} + +test "raw terminal cursor follows its active row only while that row is visible" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + for ([_]bool{ false, true }) |tag_bottom| { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.settings.tag_bottom = tag_bottom; + const pane = p.panes[0].?; + pane.mode = .tty; + for (0..60) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "row\r\n" } }); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3;4H" } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const live = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + const rect = p.rects[0]; + const body_y = if (tag_bottom) rect.y else rect.y + BOX_H; + try std.testing.expectEqual(rect.x + config.GUTTER + 3, live.x); + try std.testing.expectEqual(body_y + 2, live.y); + + panes.Terminal.scrollGrid(pane, -1); + const scrolled = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(live.x, scrolled.x); + try std.testing.expectEqual(live.y + 1, scrolled.y); + panes.Terminal.scrollGrid(pane, -@as(i32, pane.rows)); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + panes.Terminal.followOutput(pane); + const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(live.x, returned.x); + try std.testing.expectEqual(live.y, returned.y); + } +} + test "Esc back into a tty leaves its view at the prompt" { if (platform == .web) return; const gpa = std.testing.allocator; @@ -17168,7 +12192,7 @@ test "Esc back into a tty leaves its view at the prompt" { try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); - try std.testing.expectEqual(Mode.tty, sp.mode); + try std.testing.expectEqual(Pane.Mode.tty, sp.mode); // tty mode follows output to the bottom, so a screenful and a half of it // rides the view down and leaves that pin far up in the scrollback. @@ -17177,7 +12201,7 @@ test "Esc back into a tty leaves its view at the prompt" { 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; + const live = sp.terminal.?.vt.screens.active.pages.scrollbar().offset; try std.testing.expect(live > 0); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // out to the doc @@ -17186,16 +12210,13 @@ test "Esc back into a tty leaves its view at the prompt" { 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); + try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset); } test "Esc back into a file leaves its view where it was" { if (platform == .web) return; const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/allocators.zig" }); + const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/memory.zig" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc @@ -17250,21 +12271,18 @@ test "Shift-Esc in tty hops to the doc and leaves the shell in tty" { // 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); + try std.testing.expectEqual(Pane.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); + try std.testing.expectEqual(Pane.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(Pane.Mode.normal, shell_pane.mode); try std.testing.expectEqual(shell, p.active); } @@ -17309,9 +12327,6 @@ test "hopping between two panes does not grow the jump stack" { } 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(); @@ -17336,10 +12351,6 @@ test "only the SPC clipboard commands cross to the system clipboard" { 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); @@ -17354,9 +12365,6 @@ test "only the SPC clipboard commands cross to the system clipboard" { 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' } }); @@ -17412,13 +12420,10 @@ test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - term_pane.enterTty(p, 0); - try std.testing.expectEqual(Mode.tty, pane.mode); + panes.Terminal.enterTty(p, 0); + try std.testing.expectEqual(Pane.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 @@ -17426,13 +12431,13 @@ test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { // 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); + pane.terminal.?.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); + pane.terminal.?.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)); @@ -17452,7 +12457,7 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); // No request behind it: the window manager's own paste, or SDL answering a // Ctrl-Shift-V the desktop handled. It still has to reach the shell. p.update(.{ .paste = "ls -la" }); @@ -17470,12 +12475,8 @@ test "a paste larger than the effect ring reaches the program whole and in order const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); - // Bigger than `effect_cap * 64` (256 KiB), which is where the ring stops - // taking chunks: the tail used to be refused and the program saw 256 KiB of - // a 300 KiB paste with nothing said. Position-dependent bytes, so a - // reordered or duplicated chunk fails as loudly as a missing one. const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); for (text, 0..) |*c, i| c.* = 'a' + @as(u8, @intCast(i % 26)); @@ -17487,7 +12488,7 @@ test "a paste larger than the effect ring reaches the program whole and in order test "a bracketed paste larger than the ring still closes its bracket" { if (platform == .web) return; - if (comptime !term_pane.enabled) return; + if (comptime !panes.Terminal.enabled) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -17495,14 +12496,10 @@ test "a bracketed paste larger than the ring still closes its bracket" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); - // The program asks for brackets, so `typeToTty` emits marker, text, marker. - // The CLOSING one is queued last and was therefore the first casualty of a - // full ring: the program stayed in paste mode and read every later - // keystroke as pasted text. Worse than losing the bytes. p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); - try std.testing.expect(term_pane.bracketedPaste(p.panes[0].?)); + try std.testing.expect(panes.Terminal.bracketedPaste(p.panes[0].?)); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'z'); @@ -17522,15 +12519,12 @@ test "pasted bytes still queued at shutdown are freed, not leaked" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'q'); p.update(.{ .paste = text }); - // Parked and deliberately NOT drained — a session killed mid-paste. The - // copy is the core's, so `std.testing.allocator` fails this test through - // the `deinit` above if shutdown forgets it. try std.testing.expect(p.pending_write_bytes > 0); } @@ -17555,14 +12549,14 @@ test "leaving tty hides the prompt and keeps the command typed at it" { const rowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const rows = term_pane.shellRows(pp, pane) catch return null; + const rows = panes.Terminal.shellRows(pp, pane) catch return null; for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; const bodyRowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const body = term_pane.bodyText(pp.scratch.allocator(), pane) catch return null; + const body = panes.Terminal.bodyText(pp.scratch.allocator(), pane) catch return null; var it = std.mem.splitScalar(u8, body, '\n'); while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; @@ -17577,18 +12571,12 @@ test "leaving tty hides the prompt and keeps the command typed at it" { 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/", @@ -17624,7 +12612,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" 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); + const rows = try panes.Terminal.shellRows(p, sp); var row: i32 = 0; const at = for (rows, 0..) |r, i| { if (std.mem.indexOf(u8, r, "0123456789") != null) break i; @@ -17638,7 +12626,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" sp.cur_col = 3; sp.cur_pinned = true; while (p.nextEffect()) |_| {} - term_pane.enterTty(p, shell); + panes.Terminal.enterTty(p, shell); // The walk is arrow keys the shell understands. Seven lefts: readline's // cursor sits past the '9' and the click was on the '3'. @@ -17655,24 +12643,6 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" try std.testing.expectEqual(@as(usize, 7), lefts); } -// THE BOARD'S MEMORY PRESSURE, REPRODUCED ON AN ORDINARY NATIVE TARGET. -// -// These live in pardes.zig and not in limits.zig because every one of them -// drives `Pardes.init`: the table alone cannot say what a boot costs. The one -// test that needs nothing but the numbers — the desktop-capacity regression -// guard — stays in src/limits.zig. -// -// All three use the FixedBufferAllocator (or the accounting FailingAllocator) -// as the CORE'S OWN gpa and hand the same allocator to every `Options` arena, -// mirroring src/esp32p4.zig's `allocators.init(a)`: on the board every tier is a -// `StackFallbackAllocator` with a 4 KiB or zero buffer, so effectively all of -// it spills onto the single heap. Calling `allocators.init` here instead would -// hand a desktop build its 32 MiB static `.bss` tier and serve every request -// out of that, making a 384 KiB budget mean nothing. - -/// The board grid. These mirror `pardes_config.esp32p4_cols/esp32p4_rows` (build.zig -/// defaults, read at src/esp32p4.zig:235) rather than reading them, because a -/// native build does not set the P4 options at all. const board_cols: u16 = 56; const board_rows: u16 = 14; @@ -17680,9 +12650,6 @@ fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { return .{ .cols = cols, .rows = rows, - // No pty is spawned by a test host, but `tty_only` is the one-pane boot - // and therefore the closest a hosted build gets to the board's - // single-output-buffer boot. .tty_only = true, .frame_allocator = gpa, .image_allocator = gpa, @@ -17691,52 +12658,18 @@ fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { }; } -/// A boot and its teardown as one `!void` call. `checkAllAllocationFailures` -/// requires exactly that shape and `Pardes.init` returns `*Pardes` with a -/// `deinit` obligation, so the harness cannot call it directly. fn bootAndTearDown(gpa: std.mem.Allocator, cols: u16, rows: u16) !void { const p = try Pardes.init(gpa, boardBudgetOptions(gpa, cols, rows)); p.deinit(); } -// WHAT THE BOARD'S 384 KiB BUYS, CHECKED FROM A DESKTOP. -// -// What a hosted build CANNOT do is boot in 384 KiB, and the reason is not a -// capacity: `@sizeOf(Pane)` carries the ghostty-vt Terminal, 1.1 MiB of it, and -// what removes that on the board is `terminal_panes` — a CAPABILITY keyed on -// the platform (src/limits.zig says why it is not in the table). So there is no -// build option that turns a desktop into the board, and a test that pretended -// otherwise would be asserting a FixedBufferAllocator refuses a 2.2 MiB -// request. That was written, it asserted nothing, and it is gone. -// -// What a hosted build CAN check is every product the board's budget is spent -// on, because both factors are visible here: the board's CAPS are literals in -// src/limits.zig, and the ELEMENT SIZES are the same structs this target -// compiles (`Effect`, `Snapshot`, `Cell` and `PanelCellDiff` hold no pointers, -// so riscv32 and x86_64 agree about all four). That is the product a code -// change actually moves: nobody shrinks the board's heap, but somebody adds a -// `Buf(512)` arm to `Effect` and costs it 49 KiB it does not have. -// -// The caps are spelled as LITERALS rather than read from `limits`, because on -// this build `limits` holds the desktop numbers; these are the board's, they -// are its contract, and a derivation would agree with itself. -// -// MEASURED on x86_64-linux Debug at this commit: `@sizeOf(Effect)` 272, -// `@sizeOf(term_pane.Snapshot)` 56, `@sizeOf(Cell)` 26, `@sizeOf(PanelCellDiff)` 3 — -// so the three products are 34,816 + 1,792 + 43,120 = 79,728 bytes, a fifth of -// the heap, against bounds of 49,152 / 12,288 / 49,152 and a total of 196,608. test "board heap: every inline ring the board pays for still fits its budget" { const heap = limits.board_heap_bytes; - // THE EFFECT RING, the largest single inline cost in `Pardes` — 4096 - // entries on a desktop is 1.09 MiB of the 1.14 MiB the struct occupies. - // The board holds 128 (src/limits.zig `effect_cap`). const effect_ring = 128 * @sizeOf(Effect); // ...and the undo history, the largest in `Pane` once the terminal is out: // 16 snapshots on the board against 256 on a desktop. - const undo_history = 2 * 16 * @sizeOf(term_pane.Snapshot); - // ...and the three per-cell arrays the core owns at the board's own grid, - // which the next test pins the SHAPE of; this one pins the COST. + const undo_history = 2 * 16 * @sizeOf(panes.Terminal.Snapshot); const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff)); // Each of the three separately, so a failure names the one that grew @@ -17744,45 +12677,12 @@ test "board heap: every inline ring the board pays for still fits its budget" { try std.testing.expect(effect_ring <= heap / 8); try std.testing.expect(undo_history <= heap / 32); try std.testing.expect(grid <= heap / 8); - // ...and together, against the half of the heap the firmware measured as - // available after its own .bss, stack and vaxis's two grids. Three eighths - // is what the individual bounds already allow; asserting the sum as well is - // what catches two of them growing a little each. try std.testing.expect(effect_ring + undo_history + grid <= heap / 2); } -// EVERY CELL IS PAID FOR FOUR TIMES on the board — vaxis's `Screen` and -// `InternalScreen`, and the core's `Surface.cells` and `presented_cells` — plus -// the core's per-cell diff classification. Two of those four are vaxis's and -// invisible from here; what this pins is the three buffers the CORE owns, so -// that adding a fourth core-owned per-cell array fails loudly instead of -// quietly costing the board another 20 KiB. -// -// MEASURED at this commit: `@sizeOf(Cell)` is 26 and `@sizeOf(PanelCellDiff)` -// is 3, so the core spends 55 bytes per cell — 43,120 bytes at the board's -// 56x14 grid, an eighth of the whole heap and the largest single grid-scaled -// cost in the program. -test "board heap: the core owns exactly three per-cell arrays" { +test "board heap: transition grids fit the budget and the surface borrows its snapshots" { const per_cell = 2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff); - // Five NAMES, three BUFFERS: `Surface.previous_cells` and - // `Surface.cell_diffs` are views published onto the two the core owns (see - // `render`), so they cost nothing. Counted by reflection because a fifth - // name is exactly the change this test exists to catch. - const grid_slices = comptime blk: { - var n: usize = 0; - for (@typeInfo(Pardes).@"struct".fields ++ @typeInfo(Surface).@"struct".fields) |f| { - const info = @typeInfo(f.type); - if (info != .pointer or info.pointer.size != .slice) continue; - if (info.pointer.child == Cell or info.pointer.child == PanelCellDiff) n += 1; - } - break :blk n; - }; - try std.testing.expectEqual(@as(usize, 5), grid_slices); - - // The diff array is only allocated for a transition that needs the previous - // grid, so the sequence here is the shortest one that makes all three real: - // boot, acknowledge, split with `vertical` armed, render. const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16, .tty_only = true }); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -17791,34 +12691,53 @@ test "board heap: the core owns exactly three per-cell arrays" { p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); const cells = @as(usize, p.screen_w) * p.screen_h; try std.testing.expectEqual(cells, p.surface.cells.len); - try std.testing.expectEqual(cells, p.presented_cells.len); - try std.testing.expectEqual(cells, p.panel_cell_diffs.len); + try std.testing.expectEqual(cells, p.presentation.previous_cells.len); + try std.testing.expectEqual(cells, p.presentation.diffs.len); + try std.testing.expect(p.surface.previous_cells.ptr == p.presentation.previous_cells.ptr); + try std.testing.expect(p.surface.cell_diffs.ptr == p.presentation.diffs.ptr); + try std.testing.expect(p.surface.cells.ptr != p.surface.previous_cells.ptr); const owned = p.surface.cells.len * @sizeOf(Cell) + - p.presented_cells.len * @sizeOf(Cell) + - p.panel_cell_diffs.len * @sizeOf(PanelCellDiff); + p.presentation.previous_cells.len * @sizeOf(Cell) + + p.presentation.diffs.len * @sizeOf(PanelCellDiff); try std.testing.expectEqual(cells * per_cell, owned); - // ...and the board's own grid has to leave the other seven eighths of the - // heap for everything else. 43,120 of 49,152 at the numbers above; a cell - // that grew by two bytes would spend the margin. try std.testing.expect(@as(usize, board_cols) * board_rows * per_cell <= limits.board_heap_bytes / 8); } -// EVERY ALLOCATION IN A BOOT, FAILED IN TURN. Eight of them at this commit, so -// the sweep is eight boots and costs milliseconds. `checkAllAllocationFailures` -// is the whole test because it asserts precisely the three things that matter: -// a failed allocation surfaces `error.OutOfMemory` rather than being swallowed -// into a half-built instance, `allocated_bytes == freed_bytes` at that point -// (so the failure path needs no `deinit` and leaves nothing dangling), and the -// allocation count is deterministic. +test "LSP failure clears only its matching wait without completion indentation" { + if (!lsp.supports.contains(.completion)) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("x"); + pane.mode = .insert; + pane.cur_col = 1; + + p.lspRequest(p.active, .completion, ""); + const old_id = p.lsp_wait.?.id; + p.lspRequest(p.active, .completion, ""); + const current_id = p.lsp_wait.?.id; + p.update(.{ .lsp_resp = .{ .id = old_id, .rows = null } }); + try std.testing.expectEqual(current_id, p.lsp_wait.?.id); + p.update(.{ .lsp_resp = .{ .id = current_id, .rows = null } }); + try std.testing.expect(p.lsp_wait == null); + try std.testing.expectEqualStrings("x", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + + p.lspRequest(p.active, .completion, ""); + p.update(.{ .lsp_resp = .{ .id = p.lsp_wait.?.id, .rows = "" } }); + try std.testing.expect(p.lsp_wait == null); + try std.testing.expectEqualStrings("x ", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 4), pane.cur_col); +} + test "board heap: every allocation failure during boot is a clean OutOfMemory" { try std.testing.checkAllAllocationFailures( std.testing.allocator, -- cgit v1.3