summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig8515
1 files changed, 1717 insertions, 6798 deletions
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<Tab>` 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 <name>` 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<from><to>` 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);
+ 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);
- // Once canonical has replaced that sample, make another real transition.
- // Re-applying the effective tagline percentage is inert, but changing it
- // invalidates the frozen raster's metrics and therefore snaps the tracks.
p.acknowledgePanelPresentation(&.{});
p.col_weight[0] = column_weight_unit;
p.col_weight[1] = column_weight_unit * 2;
@@ -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=<dir>` 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=<name>` gave, which scripts need because they have to
- /// predict `$XDG_RUNTIME_DIR/pardes-9p-<name>.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),
+pub const RetiredPanes = struct {
+ current: [2 * MAX_PANES]?*Pane = @splat(null),
+ previous: [2 * MAX_PANES]?*Pane = @splat(null),
- fn doom(a: *PaneAllocator, pane: *Pane) void {
- for (&a.fresh) |*slot| if (slot.* == 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);
+ p.retired_panes.previous = p.retired_panes.current;
+ p.retired_panes.current = @splat(null);
}
- /// Visit each Pane field that holds a pane pointer; when it names a doomed
- /// pane, snapshot that pane's directory into our own bytes so the link can
- /// die with it. One field carries a pointer today (the inherited cwd); the
- /// comptime walk keeps that honest as fields come and go.
- fn fixupPaneRefs(p: *Pardes, pane: *Pane) void {
- inline for (@typeInfo(Pane).@"struct".fields) |f| {
- if (f.type == Pane.Cwd) switch (@field(pane, f.name)) {
- .inherited => |src| if (p.pane_alloc.isDoomed(src)) pane.setOwnedCwd(paneDir(src)),
- else => {},
- };
- }
- }
-
- /// Put a fully constructed pane in a free slot and give it the monotonic
- /// identity every slot-reuse guard relies on. Pane kinds construct their
- /// own payloads; this registration rule remains a core invariant.
- fn installPane(p: *Pardes, id: usize, pane: *Pane) void {
+ 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/<pid>/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 <elsewhere>` 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 <path>` 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<Tab>` 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-<the configured
- // key> 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,187 +7036,27 @@ 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,
- };
-
- fn targetColumn(p: *Pardes, cur_x: u16) usize {
- var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0;
- for (0..p.ncol) |c| {
- if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) {
- tc = c;
- break;
- }
- }
- return tc;
- }
-
- fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
- if (h < 2) return null;
- const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1;
- const lo = y +| min_each;
- const hi = y + h - min_each;
- if (lo > hi) return y + h / 2;
- return std.math.clamp(cur_y, lo, hi);
- }
-
- fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement {
- const src = p.layoutFindTerm(id) orelse return null;
- const tc = p.targetColumn(cur_x);
- if (tc == src.col and p.col_n[src.col] == 1) return null;
- if (tc == src.col) {
- const sr = p.rects[id];
- if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null;
- }
- var heights: [MAX_PANES]u16 = @splat(0);
- for (0..p.ncol) |c| {
- for (0..p.col_n[c]) |k| {
- const pid = p.col_terms[c][k];
- heights[pid] = p.rects[pid].h;
- }
- }
- if (p.col_n[src.col] > 1) {
- const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1];
- heights[sib] +|= p.rects[id].h;
- }
- var y: u16 = TOPBAR_H;
- var last: ?MovePlacement = null;
- for (0..p.col_n[tc]) |k| {
- const pid = p.col_terms[tc][k];
- if (pid == id) continue;
- const h = heights[pid];
- const row = splitRowForExtent(y, h, cur_y) orelse {
- y +|= h;
- continue;
- };
- const placement: MovePlacement = .{
- .preview_col = tc,
- .above_id = pid,
- .row = row,
- .above_y = y,
- .above_h = h,
- };
- last = placement;
- if (cur_y < y + h) return placement;
- y +|= h;
- }
- return last;
- }
-
- /// drop pane `id` below the pane under the cursor, converting on-screen
- /// heights to weights so ONLY the split pane changes size
- fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void {
- const placement = p.movePlacement(id, cur_x, cur_y) orelse return;
- const src = p.layoutFindTerm(id) orelse return;
- const source_multi = p.col_n[src.col] > 1;
- var heights: [MAX_PANES]u16 = @splat(0);
- for (0..p.ncol) |c| {
- for (0..p.col_n[c]) |k| {
- const pid = p.col_terms[c][k];
- heights[pid] = p.rects[pid].h;
- }
- }
- p.layoutRemove(id);
- if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) {
- const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx];
- heights[sib] +|= p.rects[id].h;
- }
- const af = p.layoutFindTerm(placement.above_id) orelse return;
- const upper_h = @max(1, placement.row -| placement.above_y);
- const lower_h = @max(1, placement.above_h -| upper_h);
- heights[placement.above_id] = upper_h;
- heights[id] = lower_h;
- p.layoutInsert(af.col, af.idx + 1, id);
- p.setColumnWeights(af.col, &heights);
- if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights);
- }
-
- fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void {
- if (col >= p.ncol) return;
- for (0..p.col_n[col]) |k| {
- const pid = p.col_terms[col][k];
- if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid]));
- }
- }
-
// ---- helix-modal normal mode ----
- const PaneLines = struct {
- lines: []const []const u8,
- row0: i32, // absolute row of lines[0]
- };
-
- /// The lines the cursor moves over, absolute rows. File: all content lines.
- /// Terminal: the whole history+active grid with the edit buffer's lines
- /// standing in for the rows it covers, so motions ride the scrollback and
- /// the typed text alike. Scratch-arena backed.
- /// (pub only for test/hxdiff.zig — the helix differential harness dumps
- /// this surface as a tty case's final text.)
- pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
+ pub fn paneCursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {
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,
- };
+ 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 .{ .lines = try term_pane.cursorLines(p, pane), .row0 = 0 };
+ return panes.Terminal.cursorLines(p, pane);
}
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 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(file_pane.byteAtDisplayFrom(
- pl.lines[@intCast(local)],
+ return @intCast(panes.File.byteAtDisplayFrom(
+ lines[@intCast(local)],
@intCast(@max(0, from_raw)),
@intCast(@max(0, display_col)),
));
@@ -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 };
+ const lines = try panes.Terminal.cursorLines(p, pane);
+ return panes.Terminal.flatSurface(p, pane, lines);
}
- /// Write a whole selection back — helix's `Selection::new`: min-width-1,
- /// sorted by start, overlapping ranges merged (the primary following its
- /// range through a merge). `ranges[pri]` lands in the primary slot through
- /// setPaneRange, so nothing downstream can tell it apart from a lone
- /// cursor; the rest become pane.sels. `sticky` carries each range's own
- /// j/k goal column, -1 for the ones that have none.
- fn setPaneRanges(pane: *Pane, pl: PaneLines, text: []const u8, in: []const modal.HxRange, sticky: []const i32, pri0: usize, explicit: bool) void {
- if (in.len == 0) return; // helix asserts non-empty; here it just means "no change"
- var r: [MAX_SELS]modal.HxRange = undefined;
- var st: [MAX_SELS]i32 = undefined;
- var n: usize = @min(in.len, MAX_SELS);
- var pri: usize = @min(pri0, n - 1);
- for (in[0..n], 0..) |x, i| {
- r[i] = x;
- if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head);
- st[i] = if (i < sticky.len) sticky[i] else -1;
- }
- // insertion sort by start — n is tiny and usually already ordered
- var i: usize = 1;
- while (i < n) : (i += 1) {
- var j = i;
- while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) {
- std.mem.swap(modal.HxRange, &r[j], &r[j - 1]);
- std.mem.swap(i32, &st[j], &st[j - 1]);
- if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j;
- }
- }
- // merge overlaps (helix Range::overlaps + Range::merge, kept forward:
- // a merged range takes the union and loses its direction only when the
- // two disagree, which is what helix's else-branch does)
- var k: usize = 0;
- i = 1;
- while (i < n) : (i += 1) {
- const a = r[k];
- const b = r[i];
- const af = @min(a.anchor, a.head);
- const at = @max(a.anchor, a.head);
- const bf = @min(b.anchor, b.head);
- const bt = @max(b.anchor, b.head);
- if (af == bf or (at > bf and bt > af)) {
- r[k] = if (a.anchor > a.head and b.anchor > b.head)
- .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) }
- else
- .{ .anchor = @min(af, bf), .head = @max(at, bt) };
- if (pri == i) pri = k;
- if (st[k] < 0) st[k] = st[i];
- continue;
- }
- k += 1;
- r[k] = b;
- st[k] = st[i];
- if (pri == i) pri = k;
- }
- n = k + 1;
- setPaneRange(pane, pl, text, r[pri], explicit);
- pane.sticky_col = st[pri];
- var w: usize = 0;
- for (r[0..n], 0..) |x, idx| {
- if (idx == pri) continue;
- const c = rangeCells(text, x);
- const cc = modal.hxPos(text, c.cur);
- const ac = modal.hxPos(text, c.anc);
- pane.sels[w] = .{
- .row = @as(i32, @intCast(cc.row)) + pl.row0,
- .col = @intCast(cc.col),
- .arow = @as(i32, @intCast(ac.row)) + pl.row0,
- .acol = @intCast(ac.col),
- .sticky = st[idx],
- };
- w += 1;
- }
- pane.nsel = @intCast(w);
- }
-
- /// The helix keys that act on the selection LIST rather than on the text.
- /// Each reads the whole list and writes a whole list back; none is a
- /// motion, which is why Action.scope marks them exempt from per-range
- /// replay. The goal columns are dropped on the way through — every
- /// one of these is a fresh intent about WHERE the cursors are, the same
- /// reason setPaneRange resets sticky_col.
- fn multiSelAction(pane: *Pane, pl: PaneLines, text: []const u8, kind: normal_input.Multi, cnt: usize) void {
- var rs: [MAX_SELS]modal.HxRange = undefined;
- const got = paneRanges(pane, text, pl.row0, &rs);
- const n = got.n;
- const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
- if (kind == .remove_primary) {
- if (n < 2) return; // helix: "no selections remaining"
- var out: [MAX_SELS]modal.HxRange = undefined;
- var m: usize = 0;
- for (rs[0..n], 0..) |r, i| {
- if (i == got.pri) continue;
- out[m] = r;
- m += 1;
- }
- // helix Selection::remove: the NEXT range takes over, or the
- // previous one when the primary was last
- return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl);
- }
- if (kind == .rotate_forward or kind == .rotate_backward) {
- const step = cnt % n;
- const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n;
- return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl);
- }
- if (kind == .merge) {
- // helix merge_selections: first.merge(last) — the ranges are
- // sorted, so that is simply the whole span
- const lo = @min(rs[0].anchor, rs[0].head);
- const hi = @max(rs[n - 1].anchor, rs[n - 1].head);
- const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head;
- const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi };
- return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl);
- }
- if (kind == .merge_consecutive) {
- // ranges that TOUCH become one; setPaneRanges already merges the
- // ones that overlap, so widening each by a grapheme says exactly
- // "consecutive counts as overlapping" and nothing else
- var out: [MAX_SELS]modal.HxRange = undefined;
- var m: usize = 0;
- var pri: usize = 0;
- for (rs[0..n], 0..) |r, i| {
- if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) {
- const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head));
- const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head));
- out[m - 1] = .{ .anchor = lo, .head = hi };
- if (i == got.pri) pri = m - 1;
- continue;
- }
- if (i == got.pri) pri = m;
- out[m] = r;
- m += 1;
- }
- return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl);
- }
- if (kind == .split_newline) {
- // helix selection::split_on_newline — one range per line the
- // selection covers, the newlines themselves left out
- var out: [MAX_SELS]modal.HxRange = undefined;
- var m: usize = 0;
- for (rs[0..n]) |r| {
- const from = @min(r.anchor, r.head);
- const to = @max(r.anchor, r.head);
- if (from == to) {
- if (m < MAX_SELS) {
- out[m] = r;
- m += 1;
- }
- continue;
- }
- var start = from;
- while (start < to and m < MAX_SELS) {
- const eol = modal.hxLineEndIdx(text, modal.hxLineOf(text, start));
- if (eol >= to) {
- out[m] = .{ .anchor = start, .head = to };
- m += 1;
- break;
- }
- out[m] = .{ .anchor = start, .head = eol };
- m += 1;
- start = eol + 1;
- }
- }
- if (m == 0) return;
- return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0
- }
- if (kind == .trim) {
- // helix trim_selections: whitespace off both ends; ranges that are
- // empty or all whitespace are dropped entirely
- var out: [MAX_SELS]modal.HxRange = undefined;
- var m: usize = 0;
- for (rs[0..n]) |r| {
- var from = @min(r.anchor, r.head);
- var to = @max(r.anchor, r.head);
- while (from < to and std.ascii.isWhitespace(text[from])) from += 1;
- while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1;
- if (from >= to) continue;
- out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to };
- m += 1;
- }
- if (m == 0) { // helix: collapse_selection + keep_primary_selection
- const c = modal.hxCursor(text, rs[got.pri]);
- return setPaneRange(pane, pl, text, .{ .anchor = c, .head = c }, false);
- }
- // helix: the first survivor that OVERLAPS the old primary, else the last
- const pf = @min(rs[got.pri].anchor, rs[got.pri].head);
- const pt = @max(rs[got.pri].anchor, rs[got.pri].head);
- var pri = m - 1;
- for (out[0..m], 0..) |r, i| {
- const f = @min(r.anchor, r.head);
- const t = @max(r.anchor, r.head);
- if (f == pf or (t > pf and pt > f)) {
- pri = i;
- break;
- }
- }
- return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl);
- }
- // C / Alt-C — helix copy_selection_on_line, a copy of each range on the
- // next/previous line that is long enough to hold its columns.
- // ponytail: BYTE columns, not helix's visual ones, so a TAB counts as
- // one column here. Everything else in this file measures the same way
- // (hscroll, the mouse, the renderer's gutter), and fixing it means
- // teaching all of them tab stops at once.
- const below = kind == .copy_below;
- var out: [MAX_SELS]modal.HxRange = undefined;
- var m: usize = 0;
- var pri: usize = 0;
- const nlines = modal.hxLineCount(text);
- for (rs[0..n], 0..) |r, ri| {
- const is_pri = ri == got.pri;
- // head-exclusive: back the leading end off onto its own cell
- const hp = modal.hxPos(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head);
- const ap = modal.hxPos(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor));
- const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1;
- if (m >= MAX_SELS) break;
- if (is_pri) pri = m;
- out[m] = r;
- m += 1;
- var made: usize = 0;
- var k: usize = 0;
- while (made < cnt and m < MAX_SELS) : (k += 1) {
- const d = (k + 1) * height;
- const arow = if (below) ap.row + d else ap.row -| d;
- const hrow = if (below) hp.row + d else hp.row -| d;
- if (arow >= nlines or hrow >= nlines) break;
- const a2 = modal.hxOff(text, .{ .row = arow, .col = ap.col });
- const h2 = modal.hxOff(text, .{ .row = hrow, .col = hp.col });
- // a line too short to reach the column is skipped, not clamped
- if (modal.hxPos(text, a2).col == ap.col and modal.hxPos(text, h2).col == hp.col) {
- if (is_pri) pri = m;
- out[m] = modal.hxPutCursor(text, .{ .anchor = a2, .head = a2 }, h2, true);
- m += 1;
- made += 1;
- }
- if (arow == 0 and hrow == 0) break;
- }
- }
- setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl);
- }
-
- // ---- `s` / `S`: the selection LIST from a regex ----
- // The other two list-making keys, and the only ones that need a pattern
- // typed first. They reuse the `/` input wholesale (startSearch — the tag
- // tail IS the prompt) and differ from it in one thing: the pattern is
- // re-applied on every keystroke, so the selection is the preview.
-
- /// helix `s` / `S`: arm the tag input for a regex, remembering the
- /// selection it is about to rewrite. Nothing moves yet — every keystroke
- /// below re-derives the preview from this snapshot, and Enter simply stops
- /// while Esc puts the snapshot back.
fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void {
- const pl = p.paneCursorLines(pane) catch return;
- const text = p.flatSurface(pane, pl) catch return;
- const got = paneRanges(pane, text, pl.row0, &pane.sel_snap);
+ 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,
- };
+ pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO)
}
- fn putPaneNormalState(pane: *Pane, state: normal_input.State) void {
- pane.count = state.count;
- pane.pending = switch (state.prefix) {
- .none => 0,
- .goto => config.goto_prefix,
- .view => config.view_prefix,
- .match => config.match_prefix,
- .find_fwd => config.find_char_fwd,
- .find_back => config.find_char_back,
- .till_fwd => config.till_char_fwd,
- .till_back => config.till_char_back,
- .replace => config.replace_prefix,
- .next => config.next_prefix,
- .prev => config.prev_prefix,
- };
- pane.pending2 = switch (state.match_sub) {
- .none => 0,
- .inside => config.match_inside,
- .around => config.match_around,
- .surround_add => config.surround_add,
- .surround_replace => config.surround_replace,
- .surround_delete => config.surround_delete,
- };
- pane.pending_ch = state.held_char;
- }
-
- /// Everything one keystroke may CONSUME on the way through the modal
- /// handler. A key means the same thing at every cursor, so the replay puts
- /// all of it back before each pass and keeps whatever the PRIMARY's pass
- /// left. (sticky_col is deliberately absent: it is per-range, and rides
- /// along in SelRange.sticky instead.)
const KeyState = struct {
- mode: Mode,
+ 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);
+ return panes.File.wrapWidth(pane, p.settings.wrap);
}
- /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows
- fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void {
- const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2));
- pane.scrollBy(if (down) half else -half);
- verticalMove(pane, pl, text, range, down, @intCast(half));
- }
-
- /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift
- /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0
- fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void {
- const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
- pane.scrollBy(delta);
- const top = pane.scroll();
- const last_row: i32 = @intCast(paneLineCount(pane, text) - 1);
- const cur = modal.hxCursor(text, range);
- if (delta > 0) {
- const snap: i32 = @max(0, @min(top + margin, last_row));
- const head = paneLineStart(pane, text, @intCast(snap));
- if (head <= cur) return;
- const anchor = if (pane.select) range.anchor else head;
- setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false);
- } else {
- const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row));
- const head = paneLineStart(pane, text, @intCast(snap));
- if (head >= cur) return;
- const anchor = if (pane.select) range.anchor else head;
- setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false);
- }
- }
-
- /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window)
- fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void {
- const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
- const top = pane.scroll();
- const last_row: i32 = @intCast(paneLineCount(pane, text) - 1);
- const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top);
- const n: i32 = @intCast(cnt - 1);
- var vline: i32 = switch (which) {
- .top => top + margin + n,
- .center => top + @divTrunc(last_vis, 2),
- .bottom => top + last_vis - (margin + n),
- };
- vline = @max(vline, top + margin);
- vline = @min(vline, top + last_vis - margin);
- const row: i32 = std.math.clamp(vline, 0, last_row);
- pointMove(pane, pl, text, range, paneLineStart(pane, text, @intCast(row)));
- }
-
- /// helix Range::line_range — the inclusive line span a range covers
- fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } {
- const from = @min(r.anchor, r.head);
- const to = @max(r.anchor, r.head);
- const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from);
- return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) };
- }
-
- fn lineStartOrEof(text: []const u8, line: usize) usize {
- if (line >= modal.hxLineCount(text)) return text.len;
- return modal.lineStartOffset(text, line);
- }
-
- /// helix `x` extend_line_below: full lines incl. the newline, cursor ON
- /// the last one's '\n'; an already-line-bounded selection grows downward
- fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void {
- const span = rangeLineSpan(text, range);
- const start = modal.lineStartOffset(text, span.start);
- const end = lineStartOrEof(text, span.end + 1);
- const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end;
- const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full));
- setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true);
- }
-
- /// helix `X` extend_to_line_bounds (direction kept)
- fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void {
- const span = rangeLineSpan(text, range);
- const start = modal.lineStartOffset(text, span.start);
- const end = lineStartOrEof(text, span.end + 1);
- const r: modal.HxRange = if (range.head < range.anchor)
- .{ .anchor = end, .head = start }
- else
- .{ .anchor = start, .head = end };
- setPaneRange(pane, pl, text, r, true);
- }
-
- /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched)
- fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void {
- const span = rangeLineSpan(text, range);
- if (span.start == span.end) return;
- const from = @min(range.anchor, range.head);
- const to = @max(range.anchor, range.head);
- var start = modal.lineStartOffset(text, span.start);
- var end = lineStartOrEof(text, span.end + 1);
- if (start != from) start = lineStartOrEof(text, span.start + 1);
- if (end != to) end = modal.lineStartOffset(text, span.end);
- const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
- const r: modal.HxRange = if (range.head < range.anchor)
- .{ .anchor = end, .head = start }
- else
- .{ .anchor = start, .head = end };
- setPaneRange(pane, pl, text, r, expl);
- }
-
- /// Keys that are about the selection LIST, or about the session rather
- /// than the text: they act ONCE however many cursors there are. The
- /// multi-cursor family rewrites the list wholesale, and the rest would
- /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC).
- ///
fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
- var state = paneNormalState(pane);
- const parsed = normal_input.parse(&state, normalInput(key));
- putPaneNormalState(pane, state);
+ const 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 <elsewhere>` 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);
+ try panes.Output.fillResults(p, id, dir, from, pat, content, anchor);
}
- /// Step to the next/previous row of this pane's results buffer and ACT on
- /// it — which of the two acme verbs that is comes from the buffer's own
- /// traits. A location list (every search, every language answer) Looks the
- /// leading `path:LINE:COL` word; a command list (ThemeSel, FontSel) Execs
- /// the whole row. False = no live results to step.
- ///
- /// n/N used to BE this, and are not any more (lookWalk): stepping a list
- /// of places now selects and stops, because a step that also opened meant
- /// you could not walk past a hit without landing on it. What still comes
- /// through here is what is not n/N at all: `]d`/`[d`, whose whole job is
- /// to GO to the next diagnostic, and acme's button-3, where clicking a
- /// word that names nothing searches for it and goes to the first hit.
fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
const pane = p.panes[id] orelse return false;
const rid = pane.search_pane orelse return false;
@@ -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 <count> 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<obj>` / `ma<obj>`: 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<ch>`: wrap the selection (or the cursor char) in a pair; the wrap
@@ -13026,27 +9561,25 @@ pub const Pardes = struct {
}
/// `md<ch>`: 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<from><to>`: swap the enclosing <from> pair's chars for <to>'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);
+ 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);
}
- /// 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 {
+ 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<char>` 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 <path>`, `Find <pat>`, `Grep <pat>`, `Rename <name>`,
- // `WsSymbols <sym>`, `Theme <name>`, `Font <name>` (gui only), and the
- // two verbs themselves — `Look <word>`, `Exec <cmd>`, 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,