summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 22:27:42 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit82551f77833a09b313bd3e58371582abd1aedcc4 (patch)
treeb2d0b53d1c2346bdbaefda8b3a5e965df377d26c
parent5be25178e4bfc2f390164e06697155cb0237babc (diff)
downloadpardes-82551f77833a09b313bd3e58371582abd1aedcc4.tar.gz
pardes-82551f77833a09b313bd3e58371582abd1aedcc4.zip
Move looking out of pardes.zig into look.zig
Pure move, no behaviour change (acme keeps this in look.c): expanding the word under a click (ExpandedWord, expandedWord, expandedSel, cursorWordSel), the n/N walk (LookFrom, lookEdge, lookStand, lookWalkPanes, lookPast, wholeRowSpan, lookSpanIn, lookWalk, landLookSpot, noteLookSource, armLookWalk), search results (Search, SearchStart, submitSearch, lookFirstHit, runSearch, searchStep, jumpResult), the look-hover preview (LookHoverWait, LookHoverPreview, FileWordSpan, PdfWordPreview, invalidateLookHover, cancelLookHover, lookHoverPane, noteLookHover, refreshLookHoverFromRaw, advanceLookHover) and lookAt with its targets (focusPaneLine, selectSpan, openPaneTarget, focusPaneByPath, clearNavigationSelection, resolveLookTarget, locationText, canonicalLookLocation, pdfLinkLocation, followPdfLink), with five tests, go verbatim to the end of look.zig after its word and target resolution. The methods become free functions taking `p: *Pardes`; their 143 call sites change from `p.lookAt(..)` to `look.lookAt(p, ..)` (tests reach them as `pardes.look.x`). Inside look.zig the moved code's `look.` prefix drops. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/Output.zig8
-rw-r--r--src/builtins.zig11
-rw-r--r--src/look.zig1101
-rw-r--r--src/mouse.zig7
-rw-r--r--src/ninep/ctl.zig3
-rw-r--r--src/ninep/events.zig3
-rw-r--r--src/ninep/pane.zig3
-rw-r--r--src/normal.zig14
-rw-r--r--src/pardes.zig1163
-rw-r--r--src/pdf_view.zig10
-rw-r--r--test/fs_namespace.zig2
-rw-r--r--test/output.zig10
-rw-r--r--test/panes.zig10
-rw-r--r--test/pdf.zig14
-rw-r--r--test/perf.zig4
15 files changed, 1198 insertions, 1165 deletions
diff --git a/src/Output.zig b/src/Output.zig
index 3b8100ac..674fcbc8 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -431,7 +431,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
if (traits(from).steps) {
pane.search_pane = i;
pane.search_row = anchor;
- p.armLookWalk(i);
+ look.armLookWalk(p, i);
}
return;
}
@@ -472,7 +472,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
if (traits(from).steps) {
pane.search_pane = i;
pane.search_row = anchor;
- p.armLookWalk(i);
+ look.armLookWalk(p, i);
}
return;
}
@@ -484,7 +484,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
if (traits(from).steps) {
pane.search_pane = free;
pane.search_row = anchor;
- p.armLookWalk(free);
+ look.armLookWalk(p, free);
}
}
@@ -563,7 +563,7 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
p.active = id;
pane.search_pane = free;
pane.search_row = null;
- p.armLookWalk(free);
+ look.armLookWalk(p, free);
}
fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 {
diff --git a/src/builtins.zig b/src/builtins.zig
index 074d1328..21dd4fa9 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -1,6 +1,7 @@
//! Command structs and runtime settings form the builtin registry; bindings live in config.zig.
const std = @import("std");
const pardes = @import("pardes.zig");
+const look = @import("look.zig");
const edit = @import("edit.zig");
const colors = @import("colors.zig");
const Pardes = pardes.Pardes;
@@ -213,7 +214,7 @@ pub const Look = struct {
// name on the message row would only be noise over that
pub const quiet = true;
pub fn run(c: Ctx) void {
- c.p.lookAt(c.id, c.arg orelse return);
+ look.lookAt(c.p, c.id, c.arg orelse return);
}
};
@@ -711,7 +712,7 @@ pub const Find = struct {
pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
- if (pat.len > 0) return c.p.runSearch(c.id, pat, .find, .top) catch |err|
+ if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .find, .top) catch |err|
c.p.reportError(c.id, "find", err);
c.p.startPrompt(c.pane, .{ .search = config.find_marker });
}
@@ -724,7 +725,7 @@ pub const Grep = struct {
pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true, .locations = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
- if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep, .top) catch |err|
+ if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .grep, .top) catch |err|
c.p.reportError(c.id, "grep", err);
c.p.startPrompt(c.pane, .{ .search = config.grep_marker });
}
@@ -779,11 +780,11 @@ pub const Last = struct {
i -= 1;
const j = c.p.jumps[i];
// Restore the cursor without recentering the pane's retained view.
- if (j.pane != c.id) return c.p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .keep);
+ if (j.pane != c.id) return look.focusPaneLine(c.p, j.pane, .{ .line = j.line, .col = j.col }, .keep);
}
// Nothing on the jumplist to go back to, which is the ordinary case
// for a pane that opened beside this one and was never focused.
- if (c.p.neighbourPane(c.id)) |next| c.p.focusPaneLine(next, .{ .line = 0, .col = 0 }, .keep);
+ if (c.p.neighbourPane(c.id)) |next| look.focusPaneLine(c.p, next, .{ .line = 0, .col = 0 }, .keep);
}
};
diff --git a/src/look.zig b/src/look.zig
index 5ebb199a..af0905a4 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -6,6 +6,26 @@ const config = @import("config.zig");
const pdf_enabled = @import("pardes_config").mupdf;
const fs = @import("fs.zig");
const platform_has_fs = fs.platform_has_fs;
+const layout = @import("layout.zig");
+const modal = @import("modal.zig");
+const filesystem = @import("fs.zig");
+const panes = @import("panes.zig");
+const mouse = @import("mouse.zig");
+const normal = @import("normal.zig");
+const edit = @import("edit.zig");
+const colors = @import("colors.zig");
+const Color = @import("surface.zig").Color;
+const pdf = panes.Pdf.pdf;
+const Pane = panes.Pane;
+const MAX_PANES = pardes.MAX_PANES;
+const TagHit = @import("tag_layer.zig").Hit;
+const BOX_H = pardes.BOX_H;
+const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
+const pathUnder = pardes.pathUnder;
+const Mouse = pardes.Mouse;
+const Key = pardes.Key;
+const Event = pardes.Event;
+const Pardes = pardes.Pardes;
extern "c" fn fork() c_int;
extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
@@ -353,3 +373,1084 @@ pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbu
if (isImagePath(found.path)) return .{ .image = .{ .path = found.path } };
return .{ .file = .{ .path = found.path, .at = pl.at } };
}
+
+// ---- looking inside the editor: expanding the word under a click, the n/N
+// walk over look-able text, search results, the hover preview, and lookAt,
+// which opens or focuses what a word names ----
+
+pub const LookHoverWait = struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ frames: u16 = 0,
+ body_hit: ?Mouse.BodyHit = null,
+ tag_hit: ?TagHit = null,
+};
+
+pub const LookHoverPreview = struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ sel: ?Pane.Sel,
+ file_word: ?FileWordSpan = null,
+ body_hit: ?Mouse.BodyHit = null,
+ tag_hit: ?TagHit = null,
+};
+
+pub const FileWordSpan = struct {
+ row: i32,
+ lo: i32,
+ hi: i32, // exclusive
+};
+
+pub const PdfWordPreview = if (pdf_enabled) struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ probe: panes.Pdf.WordProbe,
+
+ fn deinit(preview: *@This(), gpa: std.mem.Allocator) void {
+ preview.probe.deinit(gpa);
+ preview.* = undefined;
+ }
+} else void;
+
+fn noteLookSource(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ var w: usize = 0;
+ for (p.look_src[0..p.n_look_src]) |s| {
+ if (s == pane.serial) continue;
+ p.look_src[w] = s;
+ w += 1;
+ }
+ if (w == p.look_src.len) {
+ std.mem.copyForwards(u32, p.look_src[0 .. w - 1], p.look_src[1..w]);
+ w -= 1;
+ }
+ p.look_src[w] = pane.serial;
+ p.n_look_src = w + 1;
+ p.look_walk_owner = pane.serial;
+}
+
+pub fn armLookWalk(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.file) |file| if (file.content.len == 0) return;
+ p.look_walk_owner = pane.serial;
+}
+
+pub fn invalidateLookHover(p: *Pardes, id: usize) void {
+ if (lookHoverPane(p) != id) return;
+ p.raw_hover_intent = false;
+ cancelLookHover(p);
+}
+
+const ExpandedWord = struct { lo: usize, hi: usize };
+
+pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
+ if (col >= line.len) return null;
+ const b = config.wordBounds(line, col);
+ const command_run = b.hi > b.lo and config.commandWord(line[b.lo..b.hi]) != null;
+ if (!config.isFileChar(line[col]) and !command_run) return null;
+ if (b.hi <= b.lo) return null;
+ return .{ .lo = b.lo, .hi = b.hi };
+}
+
+/// acme: a no-drag middle/right click expands to the word under it —
+/// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling)
+pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
+ var sl = at;
+ if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl;
+ const text = edit.paneText(p, pane) catch return null;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ while (it.next()) |line| : (v += 1) {
+ if (v != sl.r0) continue;
+ const display_col: usize = @intCast(@max(0, sl.c0));
+ const col = panes.File.renderedLineByteCol(pane, v, line, display_col);
+ const b = expandedWord(line, col) orelse return null;
+ sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo));
+ sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1);
+ return sl;
+ }
+ return null;
+}
+
+/// the word under the modal cursor as a pane-local selection (paneText
+/// coords: row 0 is the tag; file panes carry the line-number prefix)
+pub fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel {
+ const w = pane.wrapRow(pane.cur_row, pane.cur_col);
+ const vrow = w.row + @as(i32, BOX_H);
+ const vcol = if (pane.file != null)
+ panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane))
+ else blk: {
+ const lines = edit.paneCursorLines(p, pane) catch break :blk pane.cur_col;
+ const local = pane.cur_row;
+ if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col;
+ break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col)));
+ };
+ return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
+}
+
+const Search = enum { text, find, grep };
+
+const SearchStart = enum { top, cursor };
+
+pub fn submitSearch(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (normal.selRegexArmed(pane)) |a| return normal.applySelRegex(p, pane, a.pat, a.split);
+ const armed = pane.promptText() orelse return;
+ const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
+ if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
+ if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]);
+ const kind: Search = if (std.mem.startsWith(u8, armed, config.find_marker))
+ .find
+ else if (std.mem.startsWith(u8, armed, config.grep_marker))
+ .grep
+ else
+ .text;
+ runSearch(p, id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err);
+ if (kind == .text) lookFirstHit(p, id);
+}
+
+fn lookFirstHit(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const rid = pane.search_pane orelse return;
+ const rp = p.panes[rid] orelse return;
+ if ((rp.file orelse return).content.len == 0) return;
+ p.active = rid;
+ lookWalk(p, 1);
+ if (p.active != rid) return; // walked out of the list; leave it there
+ const txt = edit.currentSelText(p, rp) orelse return;
+ // spent, exactly as the look chord spends it (handleKey)
+ rp.vsel.active = false;
+ rp.msel.active = false;
+ rp.select = false;
+ p.runBuiltin(config.look_cmd, rid, "", txt);
+}
+
+pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void {
+ const pane = p.panes[id] orelse return;
+ const pat = std.mem.trim(u8, pat_raw, " \t\r\n");
+ if (pat.len == 0) return;
+ const arena = p.scratch.allocator();
+ // where the pane lives: a file's directory, a shell's cwd — the walk
+ // root, and the directory the results buffer is named in.
+ const dir = Pardes.paneDir(pane);
+ const out = try arena.alloc(u8, filesystem.search_max_output_bytes);
+ var out_len: usize = 0;
+ var nrows: usize = 0;
+ var anchor: ?usize = null;
+ if (kind == .grep) {
+ var roots: [MAX_PANES][]const u8 = undefined;
+ var nroots: usize = 0;
+ for (p.panes) |slot| {
+ const pp = slot orelse continue;
+ const d = Pardes.paneDir(pp);
+ var covered = false;
+ var k: usize = 0;
+ while (k < nroots) {
+ if (pathUnder(d, roots[k])) {
+ covered = true;
+ break;
+ }
+ if (pathUnder(roots[k], d)) { // this one swallows a root already kept
+ nroots -= 1;
+ roots[k] = roots[nroots];
+ continue;
+ }
+ k += 1;
+ }
+ if (covered) continue;
+ roots[nroots] = d;
+ nroots += 1;
+ }
+ for (roots[0..nroots]) |r|
+ out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]);
+ } else if (kind == .find) {
+ out_len = try filesystem.find(arena, dir, pat, out);
+ } else if (pane.hasPdf()) {
+ if (comptime pdf_enabled) {
+ const found = try panes.Pdf.searchRows(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ arena,
+ pat,
+ start == .cursor,
+ out,
+ );
+ out_len = found.bytes;
+ nrows = found.rows;
+ anchor = found.anchor;
+ }
+ } else {
+ const lines = try edit.paneCursorLines(p, pane);
+ // a real file names itself; a terminal or an output buffer has no path
+ const has_path = if (pane.file) |f| f.output == null else false;
+ var idbuf: [16]u8 = undefined;
+ // relative to `dir`, like Find's rows — and `dir` IS this file's
+ // own directory, so that is exactly its basename
+ const loc: []const u8 = if (has_path)
+ std.fs.path.basename(pane.file.?.path)
+ else
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong;
+ const cl: usize = @intCast(@max(0, pane.cur_row));
+ const cc: usize = @intCast(@max(0, pane.cur_col));
+ for (lines, 0..) |ln, i| {
+ const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue;
+ const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{
+ loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"),
+ });
+ if (row.len > out.len - out_len) break;
+ if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows;
+ @memcpy(out[out_len..][0..row.len], row);
+ out_len += row.len;
+ nrows += 1;
+ }
+ }
+ const content = try p.gpa.dupe(u8, out[0..out_len]);
+ // the buffer records WHICH search filled it, pattern and all: Find and
+ // Grep are builtins (words you can execute), the bare `/` is a key
+ const from: panes.Output.Origin = switch (kind) {
+ .text => .search,
+ .find => .{ .cmd = .Find },
+ .grep => .{ .cmd = .Grep },
+ };
+ try panes.Output.fillResults(p, id, dir, from, pat, content, anchor);
+}
+
+pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
+ const pane = p.panes[id] orelse return false;
+ const rid = pane.search_pane orelse return false;
+ const rp = p.panes[rid] orelse return false;
+ const rf = if (rp.file) |*f| f else return false;
+ // one question covers both hazards: a freed slot can be reused by an
+ // unrelated pane, and a buffer of PROSE has nowhere to step to
+ const tr = panes.Output.fileTraits(rf.output);
+ if (!tr.steps) return false;
+ // fresh results: n starts at the first row, N has nothing behind it
+ const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") +
+ @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n'));
+ var step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1;
+ if (delta == 0) return true;
+ const direction: i64 = if (delta > 0) 1 else -1;
+ while (step >= 0 and step < nrows) : (step += direction) {
+ if (!panes.Output.isResultRow(rf, @intCast(step))) continue;
+ _ = jumpResult(p, id, @intCast(step));
+ p.active = id;
+ break;
+ }
+ return true;
+}
+
+pub fn jumpResult(p: *Pardes, id: usize, row: usize) bool {
+ const pane = p.panes[id] orelse return false;
+ const rid = pane.search_pane orelse return false;
+ const rp = p.panes[rid] orelse return false;
+ const rf = if (rp.file) |*f| f else return false;
+ const tr = panes.Output.fileTraits(rf.output);
+ const ln = modal.lineSlice(rf.content, row);
+ if (ln.len == 0) return false;
+ const r: i32 = @intCast(row);
+ pane.search_row = row;
+ rp.msel = .{ .active = true, .r0 = r, .r1 = r };
+ rp.vsel.active = false;
+ rp.nsel = 0;
+ rp.cur_row = r;
+ rp.cur_pinned = true;
+ const off = rp.scroll();
+ const last = off + @as(i32, rp.rows) - 1;
+ if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last);
+ var realbuf: [4096]u8 = undefined;
+ const span = if (tr.commands)
+ wholeRowSpan(ln)
+ else
+ lineSpan(p, ln, Pardes.paneDir(rp), &realbuf) orelse blk: {
+ const target = panes.Output.location(ln);
+ break :blk if (target.at.line > 0)
+ Span{ .start = 0, .end = target.end }
+ else
+ null;
+ };
+ if (span) |selected| {
+ rp.cur_col = @intCast(selected.start);
+ rp.look_at = .{
+ .row = r,
+ .col0 = @intCast(selected.start),
+ .col1 = @intCast(selected.end - 1),
+ };
+ } else {
+ rp.cur_col = 0;
+ rp.look_at = null;
+ }
+ if (tr.commands) {
+ p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r"));
+ } else if (span) |selected| {
+ lookAt(p, rid, ln[selected.start..selected.end]);
+ // This is the newly visited result, not a selection carried into
+ // the destination. Look cleared the previous source selection.
+ rp.msel = .{ .active = true, .r0 = r, .r1 = r };
+ }
+ armLookWalk(p, rid);
+ return true;
+}
+
+const LookFrom = struct { row: i32, col: ?i32, strict: bool = false };
+
+fn lookEdge(delta: i32) LookFrom {
+ return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null };
+}
+
+fn lookStand(pane: *Pane) LookFrom {
+ if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col)
+ return .{ .row = s.row, .col = s.col0, .strict = true };
+ return .{ .row = pane.cur_row, .col = pane.cur_col };
+}
+
+fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize {
+ var n: usize = 0;
+ var i = p.n_look_src;
+ while (i > 0) {
+ i -= 1;
+ const id = p.paneBySerial(p.look_src[i]) orelse continue;
+ out[n] = id;
+ n += 1;
+ }
+ const looked = n;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ if (f.output == null) continue;
+ for (out[0..looked]) |k| {
+ if (k == id) break;
+ } else {
+ // insertion by serial descending — at most MAX_PANES
+ // comparisons, which is not a sort worth naming
+ var at = n;
+ while (at > looked and p.panes[out[at - 1]].?.serial < pane.serial) : (at -= 1)
+ out[at] = out[at - 1];
+ out[at] = id;
+ n += 1;
+ }
+ }
+ if (n == 0) {
+ if (p.panes[p.active] == null) return out[0..0];
+ out[0] = p.active;
+ n = 1;
+ }
+ return out[0..n];
+}
+
+fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool {
+ if (!on_start_row) return true;
+ const c = from.col orelse return true;
+ if (delta > 0) return if (from.strict) col0 > c else col0 >= c;
+ return if (from.strict) col0 < c else col0 <= c;
+}
+
+fn wholeRowSpan(ln: []const u8) ?Span {
+ var lo: usize = 0;
+ while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1;
+ const hi = std.mem.trimEnd(u8, ln, " \t\r").len;
+ return if (hi > lo) .{ .start = lo, .end = hi } else null;
+}
+
+fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot {
+ const lines = edit.paneCursorLines(p, pane) catch return null;
+ const nrows: i32 = @intCast(lines.len);
+ if (nrows == 0) return null;
+ const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word;
+ const dir = Pardes.paneDir(pane);
+ var realbuf: [4096]u8 = undefined;
+ const start = std.math.clamp(from.row, 0, nrows - 1);
+ var r = start;
+ while (r >= 0 and r < nrows) : (r += delta) {
+ if (budget.* == 0) return null;
+ budget.* -= 1;
+ const ln = lines[@intCast(r)];
+ if (pane.file) |*f| if (!panes.Output.isWalkRow(f, @intCast(r), ln)) continue;
+ const on_start = r == start;
+ switch (grain) {
+ .word => {
+ var best: ?Pane.LookSpot = null;
+ var i: usize = 0;
+ while (i < ln.len) {
+ while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1;
+ const t0 = i;
+ while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1;
+ if (i == t0) break;
+ const sp = wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue;
+ const col0: i32 = @intCast(t0 + sp.start);
+ if (!lookPast(col0, from, on_start, delta)) continue;
+ best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) };
+ if (delta > 0) break; // first one forward; keep the last one back
+ }
+ if (best) |b| return b;
+ },
+ // one span per row, so there is nothing to keep and nothing to
+ // scan past: the row either offers it or it does not
+ .line, .whole => {
+ const sp = span: {
+ if (grain == .whole) break :span wholeRowSpan(ln);
+ if (pane.file) |f| if (@as(usize, @intCast(r)) < f.location_rows.len) {
+ const end = f.location_rows[@intCast(r)].location_end;
+ if (end > 0) break :span @as(?Span, .{ .start = 0, .end = end });
+ };
+ break :span lineSpan(p, ln, dir, &realbuf);
+ } orelse continue;
+ const col0: i32 = @intCast(sp.start);
+ if (lookPast(col0, from, on_start, delta))
+ return .{ .row = r, .col0 = col0, .col1 = @intCast(sp.end - 1) };
+ },
+ }
+ }
+ return null;
+}
+
+pub fn lookWalk(p: *Pardes, delta: i32) void {
+ var buf: [MAX_PANES]usize = undefined;
+ const order = lookWalkPanes(p, &buf);
+ if (order.len == 0) return;
+ const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null;
+ const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0;
+ const at = if (owner) |wanted|
+ std.mem.indexOfScalar(usize, order, wanted) orelse active_at
+ else
+ active_at;
+ var from = lookStand(p.panes[order[at]] orelse return);
+ var budget: usize = Pardes.max_look_rows;
+ var k: usize = 0;
+ while (k <= order.len) : (k += 1) {
+ // `+ 2 * len` only so the backward subtraction stays unsigned
+ const idx = (if (delta > 0) at + k else at + 2 * order.len - k) % order.len;
+ const pane = p.panes[order[idx]] orelse continue;
+ if (lookSpanIn(p, pane, from, delta, &budget)) |spot|
+ return landLookSpot(p, order[idx], pane, spot);
+ if (budget == 0) return; // gave up mid-pane: stay put, stay reversible
+ from = lookEdge(delta);
+ }
+}
+
+fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void {
+ edit.pinPaneCursor(p, pane); // fresh out of tty mode the cursor still tracks the shell
+ pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true };
+ pane.msel.active = false;
+ pane.nsel = 0;
+ pane.cur_row = spot.row;
+ pane.cur_col = spot.col0;
+ pane.cur_pinned = true;
+ pane.look_at = spot;
+ p.look_walk_owner = pane.serial;
+ p.active = id;
+ pane.ensureCursorVisible();
+}
+
+pub fn cancelLookHover(p: *Pardes) void {
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| {
+ if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| {
+ if (pane.serial == preview.serial) if (pane.pdf) |*pv|
+ pv.invalidateRaster(preview.probe.page);
+ };
+ preview.deinit(p.pdf_gpa);
+ p.pdf_hover_preview = null;
+ };
+ p.look_hover_wait = null;
+ p.look_hover_preview = null;
+}
+
+pub fn lookHoverPane(p: *const Pardes) ?usize {
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| return preview.pane;
+ if (p.look_hover_preview) |preview| return preview.pane;
+ if (p.look_hover_wait) |waiting| return waiting.pane;
+ return null;
+}
+
+fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) void {
+ const delay = config.look_preview_delay_frames orelse return;
+ _ = delay;
+ const id = mouse.paneAt(p, col, row) orelse return cancelLookHover(p);
+ const pane = p.panes[id] orelse return cancelLookHover(p);
+ _ = edit.pointerTextSelection(p, id, col, row, body_hit, tag_hit) orelse return cancelLookHover(p);
+
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
+ if (p.look_hover_preview) |shown|
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and mouse.sameTagCell(shown.tag_hit, tag_hit)) return;
+ if (p.look_hover_wait) |waiting|
+ if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and mouse.sameTagCell(waiting.tag_hit, tag_hit)) return;
+
+ cancelLookHover(p);
+ p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit };
+}
+
+pub fn refreshLookHoverFromRaw(p: *Pardes) void {
+ if (!p.pointer_inside or !p.raw_hover_intent) return;
+ const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse
+ return cancelLookHover(p);
+ p.hover_col = mapped.col;
+ p.hover_row = mapped.row;
+ const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit|
+ mouse.bodyHitForPane(p, mouse.paneAt(p, mapped.col, mapped.row) orelse return cancelLookHover(p), mapped_hit, false) orelse return cancelLookHover(p)
+ else
+ null;
+ noteLookHover(p, mapped.col, mapped.row, body_hit, p.pointer_tag_hit);
+}
+
+pub fn advanceLookHover(p: *Pardes) void {
+ const delay = config.look_preview_delay_frames orelse return;
+ const waiting = if (p.look_hover_wait) |*w| w else return;
+ waiting.frames +|= 1;
+ if (waiting.frames < delay) return;
+ const pane = p.panes[waiting.pane] orelse return cancelLookHover(p);
+ if (pane.serial != waiting.serial) return cancelLookHover(p);
+ const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse
+ return cancelLookHover(p);
+ 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 cancelLookHover(p);
+ const ready = waiting.*;
+ p.look_hover_wait = null;
+ p.pdf_hover_preview = .{
+ .col = ready.col,
+ .row = ready.row,
+ .pane = ready.pane,
+ .serial = ready.serial,
+ .probe = probe,
+ };
+ pane.pdf.?.invalidateRaster(probe.page);
+ return;
+ };
+ const operand = p.pointerOperand(pane, pointed.sel);
+ if (operand.text == null or operand.text.?.len == 0) return cancelLookHover(p);
+ var preview = operand.preview;
+ if (preview) |*sel| sel.state = .done;
+ p.look_hover_preview = .{
+ .col = waiting.col,
+ .row = waiting.row,
+ .pane = waiting.pane,
+ .serial = waiting.serial,
+ .sel = preview,
+ .file_word = operand.file_word,
+ .body_hit = waiting.body_hit,
+ .tag_hit = waiting.tag_hit,
+ };
+ p.look_hover_wait = null;
+}
+
+pub fn focusPaneLine(p: *Pardes, id: usize, at: Spot, landing: enum { center, keep }) void {
+ if (id >= MAX_PANES) return;
+ const pane = p.panes[id] orelse return;
+ p.active = id;
+ pane.normal.clear();
+ if (pane.hasPdf()) {
+ if (landing == .keep) return;
+ if (comptime pdf_enabled) {
+ const pv = &pane.pdf.?;
+ if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane);
+ }
+ return;
+ }
+ if (at.line == 0) return;
+ if (pane.file) |*f| {
+ if (at.line > panes.File.nlines(p.gpa, f)) return;
+ if (landing == .center) {
+ const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
+ }
+ }
+ pane.cur_row = @intCast(at.line - 1);
+ pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0;
+ pane.cur_pinned = true;
+ if (at.end_line != 0) return selectSpan(p, pane, at);
+ pane.ensureCursorVisible();
+}
+
+fn selectSpan(p: *Pardes, pane: *Pane, at: Spot) void {
+ const text = edit.flatSurface(p, pane) catch return;
+ // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a
+ // 1-based zero is the same nothing an absent number is
+ const acol: i32 = @intCast(at.col -| 1);
+ const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32);
+ const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol);
+ pane.setRange(text, 0, r, true);
+}
+
+fn openPaneTarget(p: *const Pardes, path: []const u8, at: Spot) ?Target {
+ for (p.panes, 0..) |slot, i| {
+ const tt = slot orelse continue;
+ if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) {
+ return .{ .pane = .{ .id = i, .at = .{} } };
+ };
+ if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) {
+ return .{ .pane = .{ .id = i, .at = at } };
+ };
+ const ff = if (tt.file) |*f| f else continue;
+ if (!std.mem.eql(u8, ff.path, path)) continue;
+ return .{ .pane = .{ .id = i, .at = at } };
+ }
+ return null;
+}
+
+fn focusPaneByPath(p: *Pardes, path: []const u8, at: Spot) bool {
+ const target = openPaneTarget(p, path, at) orelse return false;
+ clearNavigationSelection(p, p.panes[target.pane.id].?);
+ focusPaneLine(p, target.pane.id, target.pane.at, .center);
+ return true;
+}
+
+/// Navigation starts a new selection at the destination.
+pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void {
+ pane.vsel = .{};
+ pane.msel = .{};
+ pane.nsel = 0;
+ pane.select = false;
+ pane.normal.clear();
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot);
+ pane.sel = @splat(.{});
+ if (comptime pdf_enabled) if (pane.pdf) |*state| {
+ state.clearDrag();
+ state.clearSelection(p.pdf_gpa);
+ };
+ if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none;
+}
+
+/// Resolve without changing focus or falling back to search. PDF links use
+/// the same location rules as an ordinary Look before offering a choice.
+fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8) Target {
+ const trimmed = std.mem.trim(u8, txt, " \t\r\n");
+ const pl = parsePathLine(trimmed);
+ if (pl.path.len > 0) if (openPaneTarget(p, pl.path, pl.at)) |target| return target;
+ var found: Target = .none;
+ var seen: [MAX_PANES][]const u8 = undefined;
+ var nseen: usize = 0;
+ var n: usize = 0;
+ cand: while (n <= p.njumps) : (n += 1) {
+ const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane;
+ if (n > 0 and k == id) continue; // tried first, skip the rerun
+ const op = p.panes[k] orelse continue;
+ const dir = Pardes.paneDir(op);
+ for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand;
+ seen[nseen] = dir;
+ nseen += 1;
+ if (pl.path.len > 0 and pl.path[0] != '/') {
+ var joinbuf: [2048]u8 = undefined;
+ if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j|
+ if (openPaneTarget(p, j, pl.at)) |target| return target;
+ }
+ found = resolve(p, txt, dir, realbuf);
+ if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break;
+ }
+ return found;
+}
+
+fn locationText(p: *Pardes, path: []const u8, at: Spot) ?[]const u8 {
+ if (std.mem.indexOfAny(u8, path, "\r\n") != null) return null;
+ const arena = p.scratch.allocator();
+ if (at.line == 0) return arena.dupe(u8, path) catch null;
+ if (at.end_line != 0) {
+ if (at.col == 0 and at.end_col == 0)
+ return std.fmt.allocPrint(arena, "{s}:{d}-{d}", .{ path, at.line, at.end_line }) catch null;
+ return std.fmt.allocPrint(arena, "{s}:{d}:{d}-{d}:{d}", .{ path, at.line, at.col, at.end_line, at.end_col }) catch null;
+ }
+ if (at.col != 0) return std.fmt.allocPrint(arena, "{s}:{d}:{d}", .{ path, at.line, at.col }) catch null;
+ return std.fmt.allocPrint(arena, "{s}:{d}", .{ path, at.line }) catch null;
+}
+
+fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 {
+ const trimmed = std.mem.trim(u8, text, " \t\r\n");
+ if (trimmed.len == 0) return null;
+ var realbuf: [4096]u8 = undefined;
+ switch (resolveLookTarget(p, id, trimmed, &realbuf)) {
+ .none => return null,
+ .url => |url| {
+ if (url.len > 256) return null;
+ for (url) |c| if (c <= 0x20 or c == 0x7f) return null;
+ return p.scratch.allocator().dupe(u8, url) catch null;
+ },
+ .file => |file| return locationText(p, file.path, file.at),
+ .image => |iv| return locationText(p, iv.path, .{}),
+ .dir => |dir| return locationText(p, dir, .{}),
+ .pane => |target| {
+ if (target.id >= p.panes.len) return null;
+ const pane = p.panes[target.id] orelse return null;
+ if (pane.file) |file| return locationText(p, file.path, target.at);
+ if (comptime pdf_enabled) if (pane.pdf) |doc| return locationText(p, doc.path, target.at);
+ if (pane.image) |iv| return locationText(p, iv.path, target.at);
+ var path: [32]u8 = undefined;
+ return locationText(p, std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at);
+ },
+ }
+}
+
+pub fn pdfLinkLocation(p: *Pardes, pane: *const Pane, link: if (pdf_enabled) pdf.Link else void) ?[]const u8 {
+ if (comptime !pdf_enabled) return null;
+ const state = pane.pdf orelse return null;
+ const id = p.paneIdOf(pane) orelse return null;
+ return switch (link.destination) {
+ .none => null,
+ .internal => |dest| if (dest.page < state.document.pages)
+ locationText(p, state.path, .{ .line = dest.page + 1 })
+ else
+ null,
+ .external => |uri| canonicalLookLocation(p, id, uri),
+ };
+}
+
+pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void {
+ if (comptime !pdf_enabled) return;
+ const pane = p.panes[id] orelse return;
+ const link = probe.link orelse return;
+ const target = pdfLinkLocation(p, pane, link) orelse return;
+ clearNavigationSelection(p, pane);
+ if (canonicalLookLocation(p, id, probe.text)) |visible| {
+ if (!std.mem.eql(u8, visible, target)) {
+ const content = std.fmt.allocPrint(p.gpa, "{s}\n{s}\n", .{ visible, target }) catch |err|
+ return p.reportError(id, "PDF link choices", err);
+ panes.Output.fillResults(p, id, std.fs.path.dirname(pane.pdf.?.path) orelse ".", .pdf_links, "", content, null) catch |err|
+ return p.reportError(id, "PDF link choices", err);
+ return;
+ }
+ }
+ switch (link.destination) {
+ .internal => |dest| {
+ p.noteHaptic(.look);
+ noteLookSource(p, id);
+ p.active = id;
+ panes.Pdf.revealOutlineDestination(p, pane, dest);
+ },
+ .external => lookAt(p, id, target),
+ .none => {},
+ }
+}
+
+pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ p.noteHaptic(.look);
+ noteLookSource(p, id);
+ const command = std.mem.trim(u8, operand, " \t\r\n");
+ if (config.commandWord(command)) |cmd| {
+ if (p.execute(id, cmd)) |dst| p.active = dst;
+ return;
+ }
+ // The operand may borrow selected terminal/PDF text released below.
+ const txt = p.scratch.allocator().dupe(u8, operand) catch return;
+ const trimmed = std.mem.trim(u8, txt, " \t\r\n");
+ clearNavigationSelection(p, pane);
+ const pl = parsePathLine(trimmed);
+ if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return;
+ var realbuf: [4096]u8 = undefined;
+ const found = resolveLookTarget(p, id, txt, &realbuf);
+ switch (found) {
+ .none => {
+ const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode;
+ if (pane.image != null or bmode == .tty) return;
+ runSearch(p, id, trimmed, .text, .cursor) catch |err| {
+ p.reportError(id, "search", err);
+ return;
+ };
+ const at = pane.search_row;
+ _ = searchStep(p, id, 1);
+ if (at != null and pane.search_row == at) {
+ pane.search_row = null;
+ _ = searchStep(p, id, 1);
+ }
+ },
+ .pane => |t| {
+ if (t.id >= MAX_PANES) return;
+ const target = p.panes[t.id] orelse return;
+ clearNavigationSelection(p, target);
+ focusPaneLine(p, t.id, t.at, .center);
+ },
+ .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
+ .dir => |dir| {
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) {
+ clearNavigationSelection(p, tt);
+ p.active = i;
+ p.emitWrite(i, "ls\r");
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
+ const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err);
+ nt.greet = true;
+ const src = layout.splitParent(p, id);
+ const f = layout.findPane(p, src).?;
+ layout.insert(p, f.col, f.idx + 1, free);
+ layout.splitBelow(p, src, nt);
+ p.active = free;
+ p.stillborn_joiner = nt.serial;
+ },
+ .file => |target| {
+ if (comptime pdf_enabled) if (target.kind == .pdf) {
+ if (focusPaneByPath(p, target.path, target.at)) return;
+ const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
+ const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
+ return p.reportError(id, "look", err);
+ p.placeDoc(id, free, nt);
+ return;
+ };
+ // focus an existing pane on this path (rescrolled), else open
+ if (focusPaneByPath(p, target.path, target.at)) return;
+ const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
+ const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
+ return p.reportError(id, "look", err);
+ if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1);
+ p.placeDoc(id, free, nt);
+ layout.compute(p);
+ nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
+ if (target.at.end_line != 0) {
+ const centered = nt.file.?.scroll;
+ selectSpan(p, nt, target.at);
+ nt.file.?.scroll = centered;
+ }
+ },
+ .image => |target| {
+ if (focusPaneByPath(p, target.path, .{})) return;
+ const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
+ const nt = panes.Image.create(p, free, target.path, &.{}) catch |err|
+ return p.reportError(id, "look", err);
+ p.placeDoc(id, free, nt);
+ },
+ }
+}
+
+test "Look hover waits without mutating the pane and input cancels it" {
+ const delay = config.look_preview_delay_frames orelse return;
+ try std.testing.expect(delay > 0);
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("alpha beta gamma\n");
+ while (p.nextEffect()) |_| {}
+ const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest;
+ _ = try p.newShell(other_id, "");
+ try std.testing.expect(layout.splitColumn(p, 0, other_id, false));
+ p.sync();
+ while (p.nextEffect()) |_| {}
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[p.active];
+ const word_x = rect.x + config.GUTTER + config.PREFIX_W + 7;
+ const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H;
+ const motion = Event{ .mouse = .{ .button = .none, .kind = .motion, .col = word_x, .row = body_y } };
+
+ const selections_before = pane.sel;
+ const active_before = p.active;
+ const cursor_before = .{ pane.cur_row, pane.cur_col };
+ const jumps_before = .{ p.njumps, p.jcur, p.n_look_src };
+ const effects_before = p.effects_len;
+
+ p.update(motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(p.animationActive());
+
+ var ticks: u16 = 0;
+ while (ticks + 1 < delay) : (ticks += 1) {
+ p.update(.tick);
+ // Repeated motion reports for one cell are noise, not a fresh delay.
+ if (ticks == delay / 2) p.update(motion);
+ try std.testing.expect(p.look_hover_preview == null);
+ }
+ p.update(.tick);
+ const preview = p.look_hover_preview orelse return error.MissingLookHoverPreview;
+ const preview_sel = preview.sel orelse return error.MissingLookHoverSelection;
+ try std.testing.expectEqual(.done, preview_sel.state);
+ try std.testing.expectEqual(@as(i32, config.PREFIX_W + 6), preview_sel.c0);
+ try std.testing.expectEqual(@as(i32, config.PREFIX_W + 9), preview_sel.c1);
+ try std.testing.expect(!p.animationActive());
+
+ // Resolving and displaying the operand is observational only.
+ try std.testing.expect(std.meta.eql(selections_before, pane.sel));
+ try std.testing.expectEqual(active_before, p.active);
+ try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col });
+ try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src });
+ try std.testing.expectEqual(effects_before, p.effects_len);
+
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ const expected_bg = colors.mix(p.theme().bg orelse p.theme().tag_bg, colors.mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg));
+ try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg);
+
+ // Background traffic in another pane does not change the text or geometry
+ // under this pointer and must not strand a stationary hover unarmed.
+ p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } });
+ try std.testing.expect(p.look_hover_preview != null);
+
+ const seam_x = p.col_x[0] + p.col_w[0] - 1;
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } });
+ _ = frame.reset(.retain_capacity);
+ const hovered_seam = try p.render(frame.allocator());
+ try std.testing.expectEqualStrings("╎", hovered_seam.at(seam_x, body_y).grapheme());
+ p.update(.pointer_leave);
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ _ = frame.reset(.retain_capacity);
+ const left_seam = try p.render(frame.allocator());
+ try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme()));
+
+ const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30;
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } });
+ for (0..delay) |_| p.update(.tick);
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(!p.animationActive());
+
+ // File PREFIX_W belongs only to body line numbers. A preview on the tag's
+ // first character must be paintable all the way at tag column zero — which
+ // is TAG_TEXT_INSET, not GUTTER: the grip keeps the gutter and the gap
+ // beside it separates the grip's hit area from the text.
+ const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y;
+ p.look_hover_preview = .{
+ .col = rect.x + TAG_TEXT_INSET,
+ .row = tag_y,
+ .pane = p.active,
+ .serial = pane.serial,
+ .sel = .{ .state = .done, .c0 = 0, .c1 = 0, .r0 = 0, .r1 = 0 },
+ };
+ _ = frame.reset(.retain_capacity);
+ const tag_surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(Color{ .rgb = expected_bg }, tag_surface.at(rect.x + TAG_TEXT_INSET, tag_y).style.bg);
+
+ p.look_hover_preview = null;
+ p.update(motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ // The following host repaint must not resurrect a hover after keyboard
+ // input cancelled its raw-pointer intent.
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(p.look_hover_wait == null);
+}
+
+test "cwd changes cancel only the affected pane's Look hover" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .shells = 3 });
+ defer p.deinit();
+
+ const first = p.panes[0] orelse return error.MissingFirstPane;
+ const other = p.panes[1] orelse return error.MissingOtherPane;
+ p.setCwd(0, "/work/first");
+ p.setCwd(1, "/work/other");
+ p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = first.serial };
+
+ // Hosts poll cwd every frame. An unchanged answer is observational and a
+ // change reported for another pane cannot invalidate this pane's operand.
+ p.setCwd(0, "/work/first");
+ try std.testing.expect(p.look_hover_wait != null);
+ p.setCwd(1, "/work/elsewhere");
+ try std.testing.expect(p.look_hover_wait != null);
+ try std.testing.expectEqual(other.serial, p.panes[1].?.serial);
+
+ p.setCwd(0, "/work/changed");
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+}
+
+test "stationary Look hover follows only acknowledged panel samples" {
+ _ = config.look_preview_delay_frames orelse return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const rect = p.rects[0];
+ const target: layout.Box = .{
+ .x = @floatFromInt(rect.x),
+ .y = @floatFromInt(rect.y),
+ .w = @floatFromInt(rect.w),
+ .h = @floatFromInt(rect.h),
+ };
+ var track: layout.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
+ .to = target,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 30, .row = 8 } });
+ const first = p.look_hover_wait orelse return error.MissingInitialHoverWait;
+
+ // Advancing the producer-side track alone cannot move the semantic cell
+ // under a stationary pointer.
+ track.frame = track.effect.frames() - 1;
+ p.presentation.tracks[0] = track;
+ refreshLookHoverFromRaw(p);
+ try std.testing.expectEqual(first, p.look_hover_wait.?);
+
+ // Once that exact sample is acknowledged, the same raw cell maps through
+ // the new panel rectangle and the debounce starts over for the new target.
+ p.acknowledgePanelPresentation(&.{track});
+ const moved = p.look_hover_wait orelse return error.MissingRemappedHoverWait;
+ try std.testing.expect(first.col != moved.col or first.row != moved.row);
+ try std.testing.expectEqual(@as(u16, 0), moved.frames);
+
+ // Re-acknowledging the same semantic target is host repaint noise, not a
+ // new hover, so it preserves the accumulated delay.
+ p.look_hover_wait.?.frames = 3;
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expectEqual(@as(u16, 3), p.look_hover_wait.?.frames);
+
+ // A zero-sized opening sample covers its future target but exposes no
+ // clickable content. Successful presentation therefore cancels the hover.
+ track.phase = .opening;
+ track.effect = .zoom;
+ track.frame = 0;
+ track.from = .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+
+ track.frame = track.effect.frames() - 1;
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.look_hover_wait != null);
+}
+
+test "Look copies its operand before releasing pointer selection storage" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n");
+ const operand = try p.gpa.dupe(u8, "/test.txt:3:2");
+ pane.sel[0] = .{ .state = .done, .source_id = 1 };
+ pane.pointer_selections[0] = .{
+ .gesture = pane.sel[0],
+ .rows = try p.gpa.alloc(Pane.PointerRow, 0),
+ .raw_text = operand,
+ };
+ p.drag = .{ .select = .{ .id = 0, .button = config.select_button } };
+ lookAt(p, 0, operand);
+ try std.testing.expectEqual(@as(i32, 2), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ try std.testing.expect(pane.pointer_selections[0] == null);
+ try std.testing.expectEqual(.none, pane.sel[0].state);
+ try std.testing.expectEqual(.none, p.drag);
+}
+
+test "Look ignores missing and out of bounds pane addresses" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("alpha\nbravo\n");
+ for ([_][]const u8{ config.pane_addr ++ "15:2", config.pane_addr ++ "999999:2" }) |operand| {
+ pane.setRange(pane.file.?.content, 0, .{ .anchor = 0, .head = 3 }, true);
+ lookAt(p, 0, operand);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expect(!pane.vsel.active);
+ }
+}
diff --git a/src/mouse.zig b/src/mouse.zig
index 7ce95302..27d5dd9c 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -2,6 +2,7 @@
//! releases (acme's chords, border and seam drags, gestures), and the
//! chrome targets a shell asks about.
const pardes = @import("pardes.zig");
+const look = @import("look.zig");
const std = @import("std");
const layout = @import("layout.zig");
const modal = @import("modal.zig");
@@ -254,12 +255,12 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
p.pointer_inside = true;
if (m.button == .none and m.kind == .motion) {
p.raw_hover_intent = true;
- p.refreshLookHoverFromRaw();
+ look.refreshLookHoverFromRaw(p);
return;
}
p.raw_hover_intent = false;
const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse {
- p.cancelLookHover();
+ look.cancelLookHover(p);
if (m.kind == .release) dragRelease(p, m.button);
return;
};
@@ -784,7 +785,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
);
defer release.deinit(p.pdf_gpa);
if (release.probe) |*probe| if (probe.link != null) {
- p.followPdfLink(s.id, probe);
+ look.followPdfLink(p, s.id, probe);
return;
};
// A native PDF page has no byte offsets of its own to
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index a3c1ab9e..25fe4037 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -6,6 +6,7 @@
//! own would say any better: `get`, `lock` and `unlock`.
const std = @import("std");
const pardes = @import("../pardes.zig");
+const look = @import("../look.zig");
const panes = @import("../panes.zig");
const config = @import("../config.zig");
const fs = @import("../fs.zig");
@@ -58,7 +59,7 @@ fn activeSerial(p: *Pardes) ?u32 {
fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const context = p.panes[id].?.serial;
const before = Serials.snapshot(p);
- if (exec) _ = p.execute(id, text) else p.lookAt(id, text);
+ if (exec) _ = p.execute(id, text) else look.lookAt(p, id, text);
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (!before.has(serial)) {
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 0e8d7bb7..d4d7fcad 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -2,6 +2,7 @@
//! bounded queues that park a read until something happens.
const std = @import("std");
const pardes = @import("../pardes.zig");
+const look = @import("../look.zig");
const tree = @import("tree.zig");
const pane_files = @import("pane.zig");
@@ -461,7 +462,7 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue;
switch (r.action) {
.body_exec, .tag_exec => _ = p.execute(live, text),
- .body_look, .tag_look => p.lookAt(live, text),
+ .body_look, .tag_look => look.lookAt(p, live, text),
else => unreachable,
}
}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 24398445..4110380a 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -3,6 +3,7 @@
//! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig).
const std = @import("std");
const pardes = @import("../pardes.zig");
+const look = @import("../look.zig");
const panes = @import("../panes.zig");
const modal = @import("../modal.zig");
const config = @import("../config.zig");
@@ -564,7 +565,7 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !
f.context_revision = null;
pane.context_rows = 0;
pane.wrap_n = 0;
- p.invalidateLookHover(id);
+ look.invalidateLookHover(p, id);
p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
events.noteLog(p, .rename, pane);
}
diff --git a/src/normal.zig b/src/normal.zig
index 7a18aae5..e273f7f1 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -2,6 +2,7 @@
//! modal actions, replayed across every selection, and each action is done
//! to the pane; PDF panes get their own normal keys.
const pardes = @import("pardes.zig");
+const look = @import("look.zig");
const std = @import("std");
const mvzr = @import("mvzr");
const modal = @import("modal.zig");
@@ -388,20 +389,20 @@ fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) v
const fwd = diagnostic.direction == .forward;
if (!diagnostic.endpoint) {
if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and
- p.searchStep(p.active, if (fwd) 1 else -1)) return;
+ look.searchStep(p, p.active, if (fwd) 1 else -1)) return;
return p.lspRequest(p.active, .diagnostics, "");
}
if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }))
return p.lspRequest(p.active, .diagnostics, "");
if (!fwd) {
pane.search_row = null;
- _ = p.searchStep(p.active, 1);
+ _ = look.searchStep(p, p.active, 1);
return;
}
const rp = p.panes[pane.search_pane orelse return] orelse return;
const rf = rp.file orelse return;
pane.search_row = std.mem.count(u8, rf.content, "\n");
- _ = p.searchStep(p.active, -1);
+ _ = look.searchStep(p, p.active, -1);
},
.move => |move| switch (move.motion) {
.left => {
@@ -552,7 +553,8 @@ fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) v
},
.pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }),
.search => return p.startPrompt(pane, .{ .search = config.search_marker }),
- .search_step => |direction| return p.lookWalk(
+ .search_step => |direction| return look.lookWalk(
+ p,
if (direction == .forward) @as(i32, 1) else -1,
),
}
@@ -595,8 +597,8 @@ pub fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void {
if (pane.tag_edit) pane.mode = .normal;
},
.search => p.startPrompt(pane, .{ .search = config.search_marker }),
- .search_forward => p.lookWalk(1),
- .search_backward => p.lookWalk(-1),
+ .search_forward => look.lookWalk(p, 1),
+ .search_backward => look.lookWalk(p, -1),
}
},
}
diff --git a/src/pardes.zig b/src/pardes.zig
index 7a451b3a..bdde1455 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -435,6 +435,7 @@ pub const Loc = struct {
};
test {
+ _ = @import("look.zig");
_ = @import("mouse.zig");
_ = @import("normal.zig");
_ = @import("edit.zig");
@@ -505,7 +506,7 @@ pub const test_api = if (@import("builtin").is_test) struct {
}
pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
- return p.searchStep(id, delta);
+ return look.searchStep(p, id, delta);
}
pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 {
@@ -1509,7 +1510,7 @@ test "a Look on a directory does not type ls into an occupied terminal" {
var host: FakeTtyQuery = .{};
host.install(p);
host.taken[0] = true;
- p.lookAt(0, tmp);
+ look.lookAt(p, 0, tmp);
var spawned = false;
while (p.nextEffect()) |effect| switch (effect) {
@@ -1522,7 +1523,7 @@ test "a Look on a directory does not type ls into an occupied terminal" {
// ...and the same look reuses that very pane once its program is gone: the
// core keeps no state of its own about it, so recovery needs nothing reset
host.taken[0] = false;
- p.lookAt(0, tmp);
+ look.lookAt(p, 0, tmp);
var sent: [64]u8 = undefined;
var sent_len: usize = 0;
while (p.nextEffect()) |effect| switch (effect) {
@@ -2575,7 +2576,7 @@ pub fn wordAtCol(str: []const u8, col: usize) []const u8 {
/// `a` IS `b` or lives under it — how Grep drops a pane's directory when
/// another pane's directory already covers it
-fn pathUnder(a: []const u8, b: []const u8) bool {
+pub fn pathUnder(a: []const u8, b: []const u8) bool {
const root = std.mem.trimEnd(u8, b, "/");
if (!std.mem.startsWith(u8, a, root)) return false;
return a.len == root.len or a[root.len] == '/';
@@ -2711,152 +2712,14 @@ test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" {
try std.testing.expectEqual(blank_x - virtual, virtual_surface.cursor.?.x);
const local_blank: i32 = @intCast(blank_x - rect.x - config.GUTTER);
- try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null);
+ try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null);
// The immediately-adjacent EOL cell and an in-line separator are blank
// too. Pointer expansion must not lean left into the preceding word.
const adjacent: i32 = @intCast(config.PREFIX_W + panes.File.displayWidth(line));
- try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null);
+ try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null);
const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len);
- try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null);
-}
-
-test "Look hover waits without mutating the pane and input cancels it" {
- const delay = config.look_preview_delay_frames orelse return;
- try std.testing.expect(delay > 0);
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = try p.setTestFile("alpha beta gamma\n");
- while (p.nextEffect()) |_| {}
- const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest;
- _ = try p.newShell(other_id, "");
- try std.testing.expect(layout.splitColumn(p, 0, other_id, false));
- p.sync();
- while (p.nextEffect()) |_| {}
-
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- const rect = p.rects[p.active];
- const word_x = rect.x + config.GUTTER + config.PREFIX_W + 7;
- const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H;
- const motion = Event{ .mouse = .{ .button = .none, .kind = .motion, .col = word_x, .row = body_y } };
-
- const selections_before = pane.sel;
- const active_before = p.active;
- const cursor_before = .{ pane.cur_row, pane.cur_col };
- const jumps_before = .{ p.njumps, p.jcur, p.n_look_src };
- const effects_before = p.effects_len;
-
- p.update(motion);
- try std.testing.expect(p.look_hover_wait != null);
- try std.testing.expect(p.look_hover_preview == null);
- try std.testing.expect(p.animationActive());
-
- var ticks: u16 = 0;
- while (ticks + 1 < delay) : (ticks += 1) {
- p.update(.tick);
- // Repeated motion reports for one cell are noise, not a fresh delay.
- if (ticks == delay / 2) p.update(motion);
- try std.testing.expect(p.look_hover_preview == null);
- }
- p.update(.tick);
- const preview = p.look_hover_preview orelse return error.MissingLookHoverPreview;
- const preview_sel = preview.sel orelse return error.MissingLookHoverSelection;
- try std.testing.expectEqual(.done, preview_sel.state);
- try std.testing.expectEqual(@as(i32, config.PREFIX_W + 6), preview_sel.c0);
- try std.testing.expectEqual(@as(i32, config.PREFIX_W + 9), preview_sel.c1);
- try std.testing.expect(!p.animationActive());
-
- // Resolving and displaying the operand is observational only.
- try std.testing.expect(std.meta.eql(selections_before, pane.sel));
- try std.testing.expectEqual(active_before, p.active);
- try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col });
- try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src });
- try std.testing.expectEqual(effects_before, p.effects_len);
-
- _ = frame.reset(.retain_capacity);
- const surface = try p.render(frame.allocator());
- const expected_bg = colors.mix(p.theme().bg orelse p.theme().tag_bg, colors.mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg));
- try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg);
-
- // Background traffic in another pane does not change the text or geometry
- // under this pointer and must not strand a stationary hover unarmed.
- p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } });
- try std.testing.expect(p.look_hover_preview != null);
-
- const seam_x = p.col_x[0] + p.col_w[0] - 1;
- p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } });
- _ = frame.reset(.retain_capacity);
- const hovered_seam = try p.render(frame.allocator());
- try std.testing.expectEqualStrings("╎", hovered_seam.at(seam_x, body_y).grapheme());
- p.update(.pointer_leave);
- try std.testing.expect(p.look_hover_wait == null);
- try std.testing.expect(p.look_hover_preview == null);
- _ = frame.reset(.retain_capacity);
- const left_seam = try p.render(frame.allocator());
- try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme()));
-
- const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30;
- p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } });
- for (0..delay) |_| p.update(.tick);
- try std.testing.expect(p.look_hover_wait == null);
- try std.testing.expect(p.look_hover_preview == null);
- try std.testing.expect(!p.animationActive());
-
- // File PREFIX_W belongs only to body line numbers. A preview on the tag's
- // first character must be paintable all the way at tag column zero — which
- // is TAG_TEXT_INSET, not GUTTER: the grip keeps the gutter and the gap
- // beside it separates the grip's hit area from the text.
- const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y;
- p.look_hover_preview = .{
- .col = rect.x + TAG_TEXT_INSET,
- .row = tag_y,
- .pane = p.active,
- .serial = pane.serial,
- .sel = .{ .state = .done, .c0 = 0, .c1 = 0, .r0 = 0, .r1 = 0 },
- };
- _ = frame.reset(.retain_capacity);
- const tag_surface = try p.render(frame.allocator());
- try std.testing.expectEqual(Color{ .rgb = expected_bg }, tag_surface.at(rect.x + TAG_TEXT_INSET, tag_y).style.bg);
-
- p.look_hover_preview = null;
- p.update(motion);
- try std.testing.expect(p.look_hover_wait != null);
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(p.look_hover_wait == null);
- try std.testing.expect(p.look_hover_preview == null);
- // The following host repaint must not resurrect a hover after keyboard
- // input cancelled its raw-pointer intent.
- p.acknowledgePanelPresentation(&.{});
- try std.testing.expect(p.look_hover_wait == null);
-}
-
-test "cwd changes cancel only the affected pane's Look hover" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .shells = 3 });
- defer p.deinit();
-
- const first = p.panes[0] orelse return error.MissingFirstPane;
- const other = p.panes[1] orelse return error.MissingOtherPane;
- p.setCwd(0, "/work/first");
- p.setCwd(1, "/work/other");
- p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = first.serial };
-
- // Hosts poll cwd every frame. An unchanged answer is observational and a
- // change reported for another pane cannot invalidate this pane's operand.
- p.setCwd(0, "/work/first");
- try std.testing.expect(p.look_hover_wait != null);
- p.setCwd(1, "/work/elsewhere");
- try std.testing.expect(p.look_hover_wait != null);
- try std.testing.expectEqual(other.serial, p.panes[1].?.serial);
-
- p.setCwd(0, "/work/changed");
- try std.testing.expect(p.look_hover_wait == null);
- try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null);
}
test "editable workspace and column tags replace selections and persist" {
@@ -3214,46 +3077,6 @@ pub const Effect = union(enum) {
}
};
-const LookHoverWait = struct {
- col: u16,
- row: u16,
- pane: usize,
- serial: u32,
- frames: u16 = 0,
- body_hit: ?Mouse.BodyHit = null,
- tag_hit: ?TagHit = null,
-};
-
-const LookHoverPreview = struct {
- col: u16,
- row: u16,
- pane: usize,
- serial: u32,
- sel: ?Pane.Sel,
- file_word: ?FileWordSpan = null,
- body_hit: ?Mouse.BodyHit = null,
- tag_hit: ?TagHit = null,
-};
-
-const FileWordSpan = struct {
- row: i32,
- lo: i32,
- hi: i32, // exclusive
-};
-
-const PdfWordPreview = if (pdf_enabled) struct {
- col: u16,
- row: u16,
- pane: usize,
- serial: u32,
- probe: panes.Pdf.WordProbe,
-
- fn deinit(preview: *@This(), gpa: std.mem.Allocator) void {
- preview.probe.deinit(gpa);
- preview.* = undefined;
- }
-} else void;
-
test "a new column takes width only from the column that created it" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 });
@@ -3712,68 +3535,6 @@ test "core composes character motion out of the new grid, not a fade" {
try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface);
}
-test "stationary Look hover follows only acknowledged panel samples" {
- _ = config.look_preview_delay_frames orelse return;
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
- defer p.deinit();
- const pane = p.panes[0].?;
- const rect = p.rects[0];
- const target: layout.Box = .{
- .x = @floatFromInt(rect.x),
- .y = @floatFromInt(rect.y),
- .w = @floatFromInt(rect.w),
- .h = @floatFromInt(rect.h),
- };
- var track: layout.Track = .{
- .serial = pane.serial,
- .pane = 0,
- .phase = .moving,
- .effect = .slide,
- .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
- .to = target,
- };
- p.acknowledgePanelPresentation(&.{track});
- p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 30, .row = 8 } });
- const first = p.look_hover_wait orelse return error.MissingInitialHoverWait;
-
- // Advancing the producer-side track alone cannot move the semantic cell
- // under a stationary pointer.
- track.frame = track.effect.frames() - 1;
- p.presentation.tracks[0] = track;
- p.refreshLookHoverFromRaw();
- try std.testing.expectEqual(first, p.look_hover_wait.?);
-
- // Once that exact sample is acknowledged, the same raw cell maps through
- // the new panel rectangle and the debounce starts over for the new target.
- p.acknowledgePanelPresentation(&.{track});
- const moved = p.look_hover_wait orelse return error.MissingRemappedHoverWait;
- try std.testing.expect(first.col != moved.col or first.row != moved.row);
- try std.testing.expectEqual(@as(u16, 0), moved.frames);
-
- // Re-acknowledging the same semantic target is host repaint noise, not a
- // new hover, so it preserves the accumulated delay.
- p.look_hover_wait.?.frames = 3;
- p.acknowledgePanelPresentation(&.{track});
- try std.testing.expectEqual(@as(u16, 3), p.look_hover_wait.?.frames);
-
- // A zero-sized opening sample covers its future target but exposes no
- // clickable content. Successful presentation therefore cancels the hover.
- track.phase = .opening;
- track.effect = .zoom;
- track.frame = 0;
- track.from = .{
- .x = target.x + target.w * 0.5,
- .y = target.y + target.h * 0.5,
- };
- p.acknowledgePanelPresentation(&.{track});
- try std.testing.expect(p.look_hover_wait == null);
- try std.testing.expect(p.look_hover_preview == null);
-
- track.frame = track.effect.frames() - 1;
- p.acknowledgePanelPresentation(&.{track});
- try std.testing.expect(p.look_hover_wait != null);
-}
-
test "repeated non-dyadic column splits preserve every unrelated boundary" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 979, .rows = 20 });
@@ -3964,9 +3725,9 @@ pub const Pardes = struct {
pointer_tag_hit: ?TagHit = null,
raw_hover_intent: bool = false,
pointer_inside: bool = false,
- look_hover_wait: ?LookHoverWait = null,
- look_hover_preview: ?LookHoverPreview = null,
- pdf_hover_preview: if (pdf_enabled) ?PdfWordPreview else void = if (pdf_enabled) null else {},
+ look_hover_wait: ?look.LookHoverWait = null,
+ look_hover_preview: ?look.LookHoverPreview = null,
+ pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {},
wheel_guard: u8 = 0,
ctrl_w_pending: bool = false,
multi_on: bool = false,
@@ -4196,7 +3957,7 @@ pub const Pardes = struct {
}
pub fn deinit(p: *Pardes) void {
- p.cancelLookHover();
+ look.cancelLookHover(p);
for (0..MAX_PANES) |id| p.dropPendingWrite(id);
for (&p.panes) |*slot| if (slot.*) |pane| {
p.teardownPane(pane);
@@ -4349,15 +4110,15 @@ pub const Pardes = struct {
// ...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();
+ if (look.lookHoverPane(p)) |h| if (h < p.panes.len and p.panes[h] == pane) look.cancelLookHover(p);
p.retired_panes.retire(pane);
}
// Retired panes keep their payloads until the following frame or core teardown.
fn teardownPane(p: *Pardes, pane: *Pane) void {
- if (p.lookHoverPane()) |hovered| {
+ if (look.lookHoverPane(p)) |hovered| {
if (hovered < p.panes.len and p.panes[hovered] == pane)
- p.cancelLookHover();
+ look.cancelLookHover(p);
}
p.shell_rows.dropPane(pane);
for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot);
@@ -4466,29 +4227,6 @@ pub const Pardes = struct {
} else null;
}
- fn noteLookSource(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- var w: usize = 0;
- for (p.look_src[0..p.n_look_src]) |s| {
- if (s == pane.serial) continue;
- p.look_src[w] = s;
- w += 1;
- }
- if (w == p.look_src.len) {
- std.mem.copyForwards(u32, p.look_src[0 .. w - 1], p.look_src[1..w]);
- w -= 1;
- }
- p.look_src[w] = pane.serial;
- p.n_look_src = w + 1;
- p.look_walk_owner = pane.serial;
- }
-
- pub fn armLookWalk(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- if (pane.file) |file| if (file.content.len == 0) return;
- p.look_walk_owner = pane.serial;
- }
-
pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void {
var off: usize = 0;
// Anything already parked for this pane owns the stream's position, so
@@ -4531,10 +4269,10 @@ pub const Pardes = struct {
};
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();
+ if (look.lookHoverPane(p) == id) look.cancelLookHover(p);
}
- fn takesCommandLine(p: *const Pardes, id: usize) bool {
+ pub fn takesCommandLine(p: *const Pardes, id: usize) bool {
const pane = p.panes[id] orelse return false;
if (!pane.isTerminal()) return false;
// A mounted shell sits behind sudo's process supervisor. Its OSC 133
@@ -4638,19 +4376,13 @@ pub const Pardes = struct {
pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool {
p.invalidate();
- p.invalidateLookHover(id);
+ look.invalidateLookHover(p, id);
const applied = p.applyWatchedFileChanged(id, bytes);
_ = p.scratch.reset(.retain_capacity);
p.sync();
return applied;
}
- pub fn invalidateLookHover(p: *Pardes, id: usize) void {
- if (p.lookHoverPane() != id) return;
- p.raw_hover_intent = false;
- p.cancelLookHover();
- }
-
pub fn emit(p: *Pardes, e: Effect) void {
if (p.effects_len == p.effects.len) return;
const tail = (p.effects_head + p.effects_len) % p.effects.len;
@@ -4886,7 +4618,7 @@ pub const Pardes = struct {
if (!ctlfs.changesPane(req)) return ctlfs.handle(p, req);
p.needs_frame = true;
p.raw_hover_intent = false;
- p.cancelLookHover();
+ look.cancelLookHover(p);
const reply = ctlfs.handle(p, req);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
@@ -4912,23 +4644,23 @@ pub const Pardes = struct {
.tick => {},
.mouse => |m| if (!(m.button == .none and m.kind == .motion)) {
p.raw_hover_intent = false;
- p.cancelLookHover();
+ look.cancelLookHover(p);
},
- .output => |o| if (p.lookHoverPane() == o.pane) {
+ .output => |o| if (look.lookHoverPane(p) == o.pane) {
p.raw_hover_intent = false;
- p.cancelLookHover();
+ look.cancelLookHover(p);
},
- .file_changed => |changed| if (p.lookHoverPane() == changed.pane) {
+ .file_changed => |changed| if (look.lookHoverPane(p) == changed.pane) {
p.raw_hover_intent = false;
- p.cancelLookHover();
+ look.cancelLookHover(p);
},
- .pdf_scroll => |scroll| if (p.lookHoverPane() == scroll.pane) {
+ .pdf_scroll => |scroll| if (look.lookHoverPane(p) == scroll.pane) {
p.raw_hover_intent = false;
- p.cancelLookHover();
+ look.cancelLookHover(p);
},
else => {
p.raw_hover_intent = false;
- p.cancelLookHover();
+ look.cancelLookHover(p);
},
}
switch (ev) {
@@ -5024,7 +4756,7 @@ pub const Pardes = struct {
.tick => {
p.chrome_animation.advance();
p.presentation.advance();
- p.advanceLookHover();
+ look.advanceLookHover(p);
},
}
_ = p.scratch.reset(.retain_capacity);
@@ -5083,7 +4815,7 @@ pub const Pardes = struct {
return pane_tail;
}
- fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize {
+ pub fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize {
for (p.panes, 0..) |slot, id| if (slot) |sp| {
if (sp == pane) return id;
};
@@ -5563,53 +5295,6 @@ pub const Pardes = struct {
if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len)));
}
- const ExpandedWord = struct { lo: usize, hi: usize };
-
- fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
- if (col >= line.len) return null;
- const b = config.wordBounds(line, col);
- const command_run = b.hi > b.lo and config.commandWord(line[b.lo..b.hi]) != null;
- if (!config.isFileChar(line[col]) and !command_run) return null;
- if (b.hi <= b.lo) return null;
- return .{ .lo = b.lo, .hi = b.hi };
- }
-
- /// acme: a no-drag middle/right click expands to the word under it —
- /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling)
- fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
- var sl = at;
- if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl;
- const text = edit.paneText(p, pane) catch return null;
- var it = std.mem.splitAny(u8, text, "\n");
- var v: i32 = 0;
- while (it.next()) |line| : (v += 1) {
- if (v != sl.r0) continue;
- const display_col: usize = @intCast(@max(0, sl.c0));
- const col = panes.File.renderedLineByteCol(pane, v, line, display_col);
- const b = expandedWord(line, col) orelse return null;
- sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo));
- sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1);
- return sl;
- }
- return null;
- }
-
- /// the word under the modal cursor as a pane-local selection (paneText
- /// coords: row 0 is the tag; file panes carry the line-number prefix)
- fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel {
- const w = pane.wrapRow(pane.cur_row, pane.cur_col);
- const vrow = w.row + @as(i32, BOX_H);
- const vcol = if (pane.file != null)
- panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane))
- else blk: {
- const lines = edit.paneCursorLines(p, pane) catch break :blk pane.cur_col;
- const local = pane.cur_row;
- if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col;
- break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col)));
- };
- return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
- }
-
fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool {
if (pane.nsel == 0) return false;
const text = edit.flatSurface(p, pane) catch return false;
@@ -5660,7 +5345,7 @@ pub const Pardes = struct {
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,
+ file_word: ?look.FileWordSpan = null,
/// A newly expanded word is installed in the gesture's button slot;
/// an existing selection is only borrowed and must not replace it.
expanded: ?Pane.Sel = null,
@@ -5731,7 +5416,7 @@ pub const Pardes = struct {
if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
const line = panes.File.sourceLine(pane, row);
const source_col: usize = @intCast(@max(0, col));
- const b = expandedWord(line, source_col) orelse return result;
+ const b = look.expandedWord(line, source_col) orelse return result;
const lo = std.math.cast(i32, b.lo) orelse return result;
const hi = std.math.cast(i32, b.hi) orelse return result;
result.text = line[b.lo..b.hi];
@@ -5751,7 +5436,7 @@ pub const Pardes = struct {
return result;
}
- const expanded = p.expandedSel(pane, clicked) orelse return result;
+ const expanded = look.expandedSel(p, pane, clicked) orelse return result;
result.text = edit.selectionText(p, pane, expanded) catch null;
if (result.text == null or result.text.?.len == 0) return result;
result.preview = expanded;
@@ -5920,7 +5605,7 @@ pub const Pardes = struct {
},
.search => |at| {
if (submit)
- p.submitSearch(p.active)
+ look.submitSearch(p, p.active)
else if (normal.selRegexArmed(pane)) |_|
normal.applySelRegex(p, pane, "", false);
pane.tag_tail_len = @min(at, pane.tag_tail_len);
@@ -5953,7 +5638,7 @@ pub const Pardes = struct {
return;
}
}
- const sel = p.expandedSel(pane, p.cursorWordSel(pane)) orelse return;
+ const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return;
const word = edit.selectionText(p, pane, sel) catch return;
p.runBuiltin(cmd, p.active, "", word);
return;
@@ -6003,9 +5688,6 @@ pub const Pardes = struct {
// ---- search (`/`, Find, n/N): results into an output buffer, look the rows ----
- const Search = enum { text, find, grep };
- const SearchStart = enum { top, cursor };
-
pub fn paneDir(pane: *const Pane) []const u8 {
switch (pane.cwd) {
.inherited => |src| return paneDir(src),
@@ -6130,205 +5812,6 @@ pub const Pardes = struct {
} });
}
- fn submitSearch(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- if (normal.selRegexArmed(pane)) |a| return normal.applySelRegex(p, pane, a.pat, a.split);
- const armed = pane.promptText() orelse return;
- const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
- if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
- if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]);
- const kind: Search = if (std.mem.startsWith(u8, armed, config.find_marker))
- .find
- else if (std.mem.startsWith(u8, armed, config.grep_marker))
- .grep
- else
- .text;
- p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err);
- if (kind == .text) p.lookFirstHit(id);
- }
-
- fn lookFirstHit(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- const rid = pane.search_pane orelse return;
- const rp = p.panes[rid] orelse return;
- if ((rp.file orelse return).content.len == 0) return;
- p.active = rid;
- p.lookWalk(1);
- if (p.active != rid) return; // walked out of the list; leave it there
- const txt = edit.currentSelText(p, rp) orelse return;
- // spent, exactly as the look chord spends it (handleKey)
- rp.vsel.active = false;
- rp.msel.active = false;
- rp.select = false;
- p.runBuiltin(config.look_cmd, rid, "", txt);
- }
-
- pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void {
- const pane = p.panes[id] orelse return;
- const pat = std.mem.trim(u8, pat_raw, " \t\r\n");
- if (pat.len == 0) return;
- const arena = p.scratch.allocator();
- // where the pane lives: a file's directory, a shell's cwd — the walk
- // root, and the directory the results buffer is named in.
- const dir = paneDir(pane);
- const out = try arena.alloc(u8, filesystem.search_max_output_bytes);
- var out_len: usize = 0;
- var nrows: usize = 0;
- var anchor: ?usize = null;
- if (kind == .grep) {
- var roots: [MAX_PANES][]const u8 = undefined;
- var nroots: usize = 0;
- for (p.panes) |slot| {
- const pp = slot orelse continue;
- const d = paneDir(pp);
- var covered = false;
- var k: usize = 0;
- while (k < nroots) {
- if (pathUnder(d, roots[k])) {
- covered = true;
- break;
- }
- if (pathUnder(roots[k], d)) { // this one swallows a root already kept
- nroots -= 1;
- roots[k] = roots[nroots];
- continue;
- }
- k += 1;
- }
- if (covered) continue;
- roots[nroots] = d;
- nroots += 1;
- }
- for (roots[0..nroots]) |r|
- out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]);
- } else if (kind == .find) {
- out_len = try filesystem.find(arena, dir, pat, out);
- } else if (pane.hasPdf()) {
- if (comptime pdf_enabled) {
- const found = try panes.Pdf.searchRows(
- &pane.pdf.?,
- p.pdf_gpa,
- arena,
- pat,
- start == .cursor,
- out,
- );
- out_len = found.bytes;
- nrows = found.rows;
- anchor = found.anchor;
- }
- } else {
- const lines = try edit.paneCursorLines(p, pane);
- // a real file names itself; a terminal or an output buffer has no path
- const has_path = if (pane.file) |f| f.output == null else false;
- var idbuf: [16]u8 = undefined;
- // relative to `dir`, like Find's rows — and `dir` IS this file's
- // own directory, so that is exactly its basename
- const loc: []const u8 = if (has_path)
- std.fs.path.basename(pane.file.?.path)
- else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong;
- const cl: usize = @intCast(@max(0, pane.cur_row));
- const cc: usize = @intCast(@max(0, pane.cur_col));
- for (lines, 0..) |ln, i| {
- const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue;
- const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{
- loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"),
- });
- if (row.len > out.len - out_len) break;
- if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows;
- @memcpy(out[out_len..][0..row.len], row);
- out_len += row.len;
- nrows += 1;
- }
- }
- const content = try p.gpa.dupe(u8, out[0..out_len]);
- // the buffer records WHICH search filled it, pattern and all: Find and
- // Grep are builtins (words you can execute), the bare `/` is a key
- const from: panes.Output.Origin = switch (kind) {
- .text => .search,
- .find => .{ .cmd = .Find },
- .grep => .{ .cmd = .Grep },
- };
- try panes.Output.fillResults(p, id, dir, from, pat, content, anchor);
- }
-
- pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
- const pane = p.panes[id] orelse return false;
- const rid = pane.search_pane orelse return false;
- const rp = p.panes[rid] orelse return false;
- const rf = if (rp.file) |*f| f else return false;
- // one question covers both hazards: a freed slot can be reused by an
- // unrelated pane, and a buffer of PROSE has nowhere to step to
- const tr = panes.Output.fileTraits(rf.output);
- if (!tr.steps) return false;
- // fresh results: n starts at the first row, N has nothing behind it
- const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") +
- @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n'));
- var step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1;
- if (delta == 0) return true;
- const direction: i64 = if (delta > 0) 1 else -1;
- while (step >= 0 and step < nrows) : (step += direction) {
- if (!panes.Output.isResultRow(rf, @intCast(step))) continue;
- _ = p.jumpResult(id, @intCast(step));
- p.active = id;
- break;
- }
- return true;
- }
-
- fn jumpResult(p: *Pardes, id: usize, row: usize) bool {
- const pane = p.panes[id] orelse return false;
- const rid = pane.search_pane orelse return false;
- const rp = p.panes[rid] orelse return false;
- const rf = if (rp.file) |*f| f else return false;
- const tr = panes.Output.fileTraits(rf.output);
- const ln = modal.lineSlice(rf.content, row);
- if (ln.len == 0) return false;
- const r: i32 = @intCast(row);
- pane.search_row = row;
- rp.msel = .{ .active = true, .r0 = r, .r1 = r };
- rp.vsel.active = false;
- rp.nsel = 0;
- rp.cur_row = r;
- rp.cur_pinned = true;
- const off = rp.scroll();
- const last = off + @as(i32, rp.rows) - 1;
- if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last);
- var realbuf: [4096]u8 = undefined;
- const span = if (tr.commands)
- wholeRowSpan(ln)
- else
- look.lineSpan(p, ln, paneDir(rp), &realbuf) orelse blk: {
- const target = panes.Output.location(ln);
- break :blk if (target.at.line > 0)
- look.Span{ .start = 0, .end = target.end }
- else
- null;
- };
- if (span) |selected| {
- rp.cur_col = @intCast(selected.start);
- rp.look_at = .{
- .row = r,
- .col0 = @intCast(selected.start),
- .col1 = @intCast(selected.end - 1),
- };
- } else {
- rp.cur_col = 0;
- rp.look_at = null;
- }
- if (tr.commands) {
- p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r"));
- } else if (span) |selected| {
- p.lookAt(rid, ln[selected.start..selected.end]);
- // This is the newly visited result, not a selection carried into
- // the destination. Look cleared the previous source selection.
- rp.msel = .{ .active = true, .r0 = r, .r1 = r };
- }
- p.armLookWalk(rid);
- return true;
- }
-
pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void {
if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary
if (!lsp.supports.contains(kind)) return;
@@ -6520,7 +6003,7 @@ pub const Pardes = struct {
const nrows = std.mem.count(u8, rows, "\n");
if (panes.Output.traits(from).jumps and nrows == 1) {
const ln = std.mem.trimEnd(u8, rows, "\n");
- return p.lookAt(w.pane, ln[0..panes.Output.location(ln).end]);
+ return look.lookAt(p, 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();
@@ -6536,260 +6019,18 @@ pub const Pardes = struct {
.line = @as(usize, @intCast(@max(0, w.row))) + 1,
.col = @as(usize, @intCast(@max(0, w.col))) + 1,
});
- _ = p.jumpResult(w.pane, row);
+ _ = look.jumpResult(p, w.pane, row);
}
}
// ---- n/N: the walk over look-able text ----
- const max_look_rows = 8192;
-
- const LookFrom = struct { row: i32, col: ?i32, strict: bool = false };
-
- fn lookEdge(delta: i32) LookFrom {
- return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null };
- }
-
- fn lookStand(pane: *Pane) LookFrom {
- if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col)
- return .{ .row = s.row, .col = s.col0, .strict = true };
- return .{ .row = pane.cur_row, .col = pane.cur_col };
- }
-
- fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize {
- var n: usize = 0;
- var i = p.n_look_src;
- while (i > 0) {
- i -= 1;
- const id = p.paneBySerial(p.look_src[i]) orelse continue;
- out[n] = id;
- n += 1;
- }
- const looked = n;
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- const f = pane.file orelse continue;
- if (f.output == null) continue;
- for (out[0..looked]) |k| {
- if (k == id) break;
- } else {
- // insertion by serial descending — at most MAX_PANES
- // comparisons, which is not a sort worth naming
- var at = n;
- while (at > looked and p.panes[out[at - 1]].?.serial < pane.serial) : (at -= 1)
- out[at] = out[at - 1];
- out[at] = id;
- n += 1;
- }
- }
- if (n == 0) {
- if (p.panes[p.active] == null) return out[0..0];
- out[0] = p.active;
- n = 1;
- }
- return out[0..n];
- }
-
- fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool {
- if (!on_start_row) return true;
- const c = from.col orelse return true;
- if (delta > 0) return if (from.strict) col0 > c else col0 >= c;
- return if (from.strict) col0 < c else col0 <= c;
- }
-
- fn wholeRowSpan(ln: []const u8) ?look.Span {
- var lo: usize = 0;
- while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1;
- const hi = std.mem.trimEnd(u8, ln, " \t\r").len;
- return if (hi > lo) .{ .start = lo, .end = hi } else null;
- }
-
- fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot {
- const lines = edit.paneCursorLines(p, pane) catch return null;
- const nrows: i32 = @intCast(lines.len);
- if (nrows == 0) return null;
- const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word;
- const dir = paneDir(pane);
- var realbuf: [4096]u8 = undefined;
- const start = std.math.clamp(from.row, 0, nrows - 1);
- var r = start;
- while (r >= 0 and r < nrows) : (r += delta) {
- if (budget.* == 0) return null;
- budget.* -= 1;
- const ln = lines[@intCast(r)];
- if (pane.file) |*f| if (!panes.Output.isWalkRow(f, @intCast(r), ln)) continue;
- const on_start = r == start;
- switch (grain) {
- .word => {
- var best: ?Pane.LookSpot = null;
- var i: usize = 0;
- while (i < ln.len) {
- while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1;
- const t0 = i;
- while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1;
- if (i == t0) break;
- const sp = look.wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue;
- const col0: i32 = @intCast(t0 + sp.start);
- if (!lookPast(col0, from, on_start, delta)) continue;
- best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) };
- if (delta > 0) break; // first one forward; keep the last one back
- }
- if (best) |b| return b;
- },
- // one span per row, so there is nothing to keep and nothing to
- // scan past: the row either offers it or it does not
- .line, .whole => {
- const sp = span: {
- if (grain == .whole) break :span wholeRowSpan(ln);
- if (pane.file) |f| if (@as(usize, @intCast(r)) < f.location_rows.len) {
- const end = f.location_rows[@intCast(r)].location_end;
- if (end > 0) break :span @as(?look.Span, .{ .start = 0, .end = end });
- };
- break :span look.lineSpan(p, ln, dir, &realbuf);
- } orelse continue;
- const col0: i32 = @intCast(sp.start);
- if (lookPast(col0, from, on_start, delta))
- return .{ .row = r, .col0 = col0, .col1 = @intCast(sp.end - 1) };
- },
- }
- }
- return null;
- }
-
- pub fn lookWalk(p: *Pardes, delta: i32) void {
- var buf: [MAX_PANES]usize = undefined;
- const order = p.lookWalkPanes(&buf);
- if (order.len == 0) return;
- const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null;
- const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0;
- const at = if (owner) |wanted|
- std.mem.indexOfScalar(usize, order, wanted) orelse active_at
- else
- active_at;
- var from = lookStand(p.panes[order[at]] orelse return);
- var budget: usize = max_look_rows;
- var k: usize = 0;
- while (k <= order.len) : (k += 1) {
- // `+ 2 * len` only so the backward subtraction stays unsigned
- const idx = (if (delta > 0) at + k else at + 2 * order.len - k) % order.len;
- const pane = p.panes[order[idx]] orelse continue;
- if (p.lookSpanIn(pane, from, delta, &budget)) |spot|
- return p.landLookSpot(order[idx], pane, spot);
- if (budget == 0) return; // gave up mid-pane: stay put, stay reversible
- from = lookEdge(delta);
- }
- }
-
- fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void {
- edit.pinPaneCursor(p, pane); // fresh out of tty mode the cursor still tracks the shell
- pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true };
- pane.msel.active = false;
- pane.nsel = 0;
- pane.cur_row = spot.row;
- pane.cur_col = spot.col0;
- pane.cur_pinned = true;
- pane.look_at = spot;
- p.look_walk_owner = pane.serial;
- p.active = id;
- pane.ensureCursorVisible();
- }
+ pub const max_look_rows = 8192;
// ---- edit operations (d / c / y / p) ----
// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
- pub fn cancelLookHover(p: *Pardes) void {
- if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| {
- if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| {
- if (pane.serial == preview.serial) if (pane.pdf) |*pv|
- pv.invalidateRaster(preview.probe.page);
- };
- preview.deinit(p.pdf_gpa);
- p.pdf_hover_preview = null;
- };
- p.look_hover_wait = null;
- p.look_hover_preview = null;
- }
-
- fn lookHoverPane(p: *const Pardes) ?usize {
- if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| return preview.pane;
- if (p.look_hover_preview) |preview| return preview.pane;
- if (p.look_hover_wait) |waiting| return waiting.pane;
- return null;
- }
-
- fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) void {
- const delay = config.look_preview_delay_frames orelse return;
- _ = delay;
- const id = mouse.paneAt(p, col, row) orelse return p.cancelLookHover();
- const pane = p.panes[id] orelse return p.cancelLookHover();
- _ = edit.pointerTextSelection(p, id, col, row, body_hit, tag_hit) orelse return p.cancelLookHover();
-
- if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
- if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
- if (p.look_hover_preview) |shown|
- if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and mouse.sameTagCell(shown.tag_hit, tag_hit)) return;
- if (p.look_hover_wait) |waiting|
- if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and mouse.sameTagCell(waiting.tag_hit, tag_hit)) return;
-
- p.cancelLookHover();
- p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit };
- }
-
- pub fn refreshLookHoverFromRaw(p: *Pardes) void {
- if (!p.pointer_inside or !p.raw_hover_intent) return;
- const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse
- return p.cancelLookHover();
- p.hover_col = mapped.col;
- p.hover_row = mapped.row;
- const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit|
- mouse.bodyHitForPane(p, mouse.paneAt(p, mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover()
- else
- null;
- p.noteLookHover(mapped.col, mapped.row, body_hit, p.pointer_tag_hit);
- }
-
- fn advanceLookHover(p: *Pardes) void {
- const delay = config.look_preview_delay_frames orelse return;
- const waiting = if (p.look_hover_wait) |*w| w else return;
- waiting.frames +|= 1;
- if (waiting.frames < delay) return;
- const pane = p.panes[waiting.pane] orelse return p.cancelLookHover();
- if (pane.serial != waiting.serial) return p.cancelLookHover();
- const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse
- return p.cancelLookHover();
- if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) {
- const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse
- return p.cancelLookHover();
- const ready = waiting.*;
- p.look_hover_wait = null;
- p.pdf_hover_preview = .{
- .col = ready.col,
- .row = ready.row,
- .pane = ready.pane,
- .serial = ready.serial,
- .probe = probe,
- };
- pane.pdf.?.invalidateRaster(probe.page);
- return;
- };
- const operand = p.pointerOperand(pane, pointed.sel);
- if (operand.text == null or operand.text.?.len == 0) return p.cancelLookHover();
- var preview = operand.preview;
- if (preview) |*sel| sel.state = .done;
- p.look_hover_preview = .{
- .col = waiting.col,
- .row = waiting.row,
- .pane = waiting.pane,
- .serial = waiting.serial,
- .sel = preview,
- .file_word = operand.file_word,
- .body_hit = waiting.body_hit,
- .tag_hit = waiting.tag_hit,
- };
- p.look_hover_wait = null;
- }
-
pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void {
p.presentation.acknowledge(p, tracks);
if (p.pointer_inside and p.drag != .none) {
@@ -6799,12 +6040,12 @@ pub const Pardes = struct {
mouse.dragUpdate(p, mapped.col, mapped.row, p.pointer_body_hit);
}
}
- p.refreshLookHoverFromRaw();
+ look.refreshLookHoverFromRaw(p);
}
pub fn abandonPanelAnimations(p: *Pardes) void {
p.presentation.cancel();
- p.cancelLookHover();
+ look.cancelLookHover(p);
}
/// A native scene pass proved unavailable. Config reads these exact bits,
@@ -6947,289 +6188,6 @@ pub const Pardes = struct {
// ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void {
- if (id >= MAX_PANES) return;
- const pane = p.panes[id] orelse return;
- p.active = id;
- pane.normal.clear();
- if (pane.hasPdf()) {
- if (landing == .keep) return;
- if (comptime pdf_enabled) {
- const pv = &pane.pdf.?;
- if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane);
- }
- return;
- }
- if (at.line == 0) return;
- if (pane.file) |*f| {
- if (at.line > panes.File.nlines(p.gpa, f)) return;
- if (landing == .center) {
- const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
- if (next != f.scroll) {
- f.scroll = next;
- f.syntax_dirty = true;
- }
- }
- }
- pane.cur_row = @intCast(at.line - 1);
- pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0;
- pane.cur_pinned = true;
- if (at.end_line != 0) return p.selectSpan(pane, at);
- pane.ensureCursorVisible();
- }
-
- fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void {
- const text = edit.flatSurface(p, pane) catch return;
- // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a
- // 1-based zero is the same nothing an absent number is
- const acol: i32 = @intCast(at.col -| 1);
- const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32);
- const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol);
- pane.setRange(text, 0, r, true);
- }
-
- fn openPaneTarget(p: *const Pardes, path: []const u8, at: look.Spot) ?look.Target {
- for (p.panes, 0..) |slot, i| {
- const tt = slot orelse continue;
- if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) {
- return .{ .pane = .{ .id = i, .at = .{} } };
- };
- if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) {
- return .{ .pane = .{ .id = i, .at = at } };
- };
- const ff = if (tt.file) |*f| f else continue;
- if (!std.mem.eql(u8, ff.path, path)) continue;
- return .{ .pane = .{ .id = i, .at = at } };
- }
- return null;
- }
-
- fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool {
- const target = p.openPaneTarget(path, at) orelse return false;
- p.clearNavigationSelection(p.panes[target.pane.id].?);
- p.focusPaneLine(target.pane.id, target.pane.at, .center);
- return true;
- }
-
- /// Navigation starts a new selection at the destination.
- pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void {
- pane.vsel = .{};
- pane.msel = .{};
- pane.nsel = 0;
- pane.select = false;
- pane.normal.clear();
- pane.append_at = null;
- pane.sticky_col = -1;
- for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot);
- pane.sel = @splat(.{});
- if (comptime pdf_enabled) if (pane.pdf) |*state| {
- state.clearDrag();
- state.clearSelection(p.pdf_gpa);
- };
- if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none;
- }
-
- /// Resolve without changing focus or falling back to search. PDF links use
- /// the same location rules as an ordinary Look before offering a choice.
- fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8) look.Target {
- const trimmed = std.mem.trim(u8, txt, " \t\r\n");
- const pl = look.parsePathLine(trimmed);
- if (pl.path.len > 0) if (p.openPaneTarget(pl.path, pl.at)) |target| return target;
- var found: look.Target = .none;
- var seen: [MAX_PANES][]const u8 = undefined;
- var nseen: usize = 0;
- var n: usize = 0;
- cand: while (n <= p.njumps) : (n += 1) {
- const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane;
- if (n > 0 and k == id) continue; // tried first, skip the rerun
- const op = p.panes[k] orelse continue;
- const dir = paneDir(op);
- for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand;
- seen[nseen] = dir;
- nseen += 1;
- if (pl.path.len > 0 and pl.path[0] != '/') {
- var joinbuf: [2048]u8 = undefined;
- if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j|
- if (p.openPaneTarget(j, pl.at)) |target| return target;
- }
- found = look.resolve(p, txt, dir, realbuf);
- if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break;
- }
- return found;
- }
-
- fn locationText(p: *Pardes, path: []const u8, at: look.Spot) ?[]const u8 {
- if (std.mem.indexOfAny(u8, path, "\r\n") != null) return null;
- const arena = p.scratch.allocator();
- if (at.line == 0) return arena.dupe(u8, path) catch null;
- if (at.end_line != 0) {
- if (at.col == 0 and at.end_col == 0)
- return std.fmt.allocPrint(arena, "{s}:{d}-{d}", .{ path, at.line, at.end_line }) catch null;
- return std.fmt.allocPrint(arena, "{s}:{d}:{d}-{d}:{d}", .{ path, at.line, at.col, at.end_line, at.end_col }) catch null;
- }
- if (at.col != 0) return std.fmt.allocPrint(arena, "{s}:{d}:{d}", .{ path, at.line, at.col }) catch null;
- return std.fmt.allocPrint(arena, "{s}:{d}", .{ path, at.line }) catch null;
- }
-
- fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 {
- const trimmed = std.mem.trim(u8, text, " \t\r\n");
- if (trimmed.len == 0) return null;
- var realbuf: [4096]u8 = undefined;
- switch (p.resolveLookTarget(id, trimmed, &realbuf)) {
- .none => return null,
- .url => |url| {
- if (url.len > 256) return null;
- for (url) |c| if (c <= 0x20 or c == 0x7f) return null;
- return p.scratch.allocator().dupe(u8, url) catch null;
- },
- .file => |file| return p.locationText(file.path, file.at),
- .image => |iv| return p.locationText(iv.path, .{}),
- .dir => |dir| return p.locationText(dir, .{}),
- .pane => |target| {
- if (target.id >= p.panes.len) return null;
- const pane = p.panes[target.id] orelse return null;
- if (pane.file) |file| return p.locationText(file.path, target.at);
- if (comptime pdf_enabled) if (pane.pdf) |doc| return p.locationText(doc.path, target.at);
- if (pane.image) |iv| return p.locationText(iv.path, target.at);
- var path: [32]u8 = undefined;
- return p.locationText(std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at);
- },
- }
- }
-
- pub fn pdfLinkLocation(p: *Pardes, pane: *const Pane, link: if (pdf_enabled) pdf.Link else void) ?[]const u8 {
- if (comptime !pdf_enabled) return null;
- const state = pane.pdf orelse return null;
- const id = p.paneIdOf(pane) orelse return null;
- return switch (link.destination) {
- .none => null,
- .internal => |dest| if (dest.page < state.document.pages)
- p.locationText(state.path, .{ .line = dest.page + 1 })
- else
- null,
- .external => |uri| p.canonicalLookLocation(id, uri),
- };
- }
-
- pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void {
- if (comptime !pdf_enabled) return;
- const pane = p.panes[id] orelse return;
- const link = probe.link orelse return;
- const target = p.pdfLinkLocation(pane, link) orelse return;
- p.clearNavigationSelection(pane);
- if (p.canonicalLookLocation(id, probe.text)) |visible| {
- if (!std.mem.eql(u8, visible, target)) {
- const content = std.fmt.allocPrint(p.gpa, "{s}\n{s}\n", .{ visible, target }) catch |err|
- return p.reportError(id, "PDF link choices", err);
- panes.Output.fillResults(p, id, std.fs.path.dirname(pane.pdf.?.path) orelse ".", .pdf_links, "", content, null) catch |err|
- return p.reportError(id, "PDF link choices", err);
- return;
- }
- }
- switch (link.destination) {
- .internal => |dest| {
- p.noteHaptic(.look);
- p.noteLookSource(id);
- p.active = id;
- panes.Pdf.revealOutlineDestination(p, pane, dest);
- },
- .external => p.lookAt(id, target),
- .none => {},
- }
- }
-
- pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
- const pane = p.panes[id] orelse return;
- p.noteHaptic(.look);
- p.noteLookSource(id);
- const command = std.mem.trim(u8, operand, " \t\r\n");
- if (config.commandWord(command)) |cmd| {
- if (p.execute(id, cmd)) |dst| p.active = dst;
- return;
- }
- // The operand may borrow selected terminal/PDF text released below.
- const txt = p.scratch.allocator().dupe(u8, operand) catch return;
- const trimmed = std.mem.trim(u8, txt, " \t\r\n");
- p.clearNavigationSelection(pane);
- const pl = look.parsePathLine(trimmed);
- if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return;
- var realbuf: [4096]u8 = undefined;
- const found = p.resolveLookTarget(id, txt, &realbuf);
- switch (found) {
- .none => {
- const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode;
- if (pane.image != null or bmode == .tty) return;
- p.runSearch(id, trimmed, .text, .cursor) catch |err| {
- p.reportError(id, "search", err);
- return;
- };
- const at = pane.search_row;
- _ = p.searchStep(id, 1);
- if (at != null and pane.search_row == at) {
- pane.search_row = null;
- _ = p.searchStep(id, 1);
- }
- },
- .pane => |t| {
- if (t.id >= MAX_PANES) return;
- const target = p.panes[t.id] orelse return;
- p.clearNavigationSelection(target);
- p.focusPaneLine(t.id, t.at, .center);
- },
- .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
- .dir => |dir| {
- for (p.panes, 0..) |slot, i| {
- if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) {
- p.clearNavigationSelection(tt);
- p.active = i;
- p.emitWrite(i, "ls\r");
- return;
- };
- }
- const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err);
- nt.greet = true;
- const src = layout.splitParent(p, id);
- const f = layout.findPane(p, src).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
- },
- .file => |target| {
- if (comptime pdf_enabled) if (target.kind == .pdf) {
- if (p.focusPaneByPath(target.path, target.at)) return;
- const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
- return p.reportError(id, "look", err);
- p.placeDoc(id, free, nt);
- return;
- };
- // focus an existing pane on this path (rescrolled), else open
- if (p.focusPaneByPath(target.path, target.at)) return;
- const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
- return p.reportError(id, "look", err);
- if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1);
- p.placeDoc(id, free, nt);
- layout.compute(p);
- nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
- if (target.at.end_line != 0) {
- const centered = nt.file.?.scroll;
- p.selectSpan(nt, target.at);
- nt.file.?.scroll = centered;
- }
- },
- .image => |target| {
- if (p.focusPaneByPath(target.path, .{})) return;
- const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.Image.create(p, free, target.path, &.{}) catch |err|
- return p.reportError(id, "look", err);
- p.placeDoc(id, free, nt);
- },
- }
- }
-
const max_exec_depth = 8;
pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize {
@@ -7845,10 +6803,10 @@ pub const Pardes = struct {
if (j.pane >= MAX_PANES) return;
const pane = p.panes[j.pane] orelse return;
if (pane.serial != j.serial) return;
- if (p.panes[p.active]) |source| p.clearNavigationSelection(source);
- if (p.active != j.pane) p.clearNavigationSelection(pane);
+ if (p.panes[p.active]) |source| look.clearNavigationSelection(p, source);
+ if (p.active != j.pane) look.clearNavigationSelection(p, pane);
p.jcur = @intCast(next);
- p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .center);
+ look.focusPaneLine(p, j.pane, .{ .line = j.line, .col = j.col }, .center);
}
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
@@ -7921,7 +6879,7 @@ pub const Pardes = struct {
return p.presentation.animating();
}
- fn noteHaptic(p: *Pardes, pulse: Haptic) void {
+ pub fn noteHaptic(p: *Pardes, pulse: Haptic) void {
if (comptime platform != .macos) return;
if (p.haptic == .look) return;
p.haptic = pulse;
@@ -9262,7 +8220,7 @@ test "Esc back into a file leaves its view where it was" {
try std.testing.expect(dp.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows));
// The other arm still centres: a look target, a `:NN`, a search hit.
- p.focusPaneLine(doc, .{ .line = @intCast(row + 1), .col = 1 }, .center);
+ look.focusPaneLine(p, doc, .{ .line = @intCast(row + 1), .col = 1 }, .center);
try std.testing.expect(dp.file.?.scroll != view);
try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll);
}
@@ -9724,39 +8682,6 @@ test "board heap: every allocation failure during boot is a clean OutOfMemory" {
);
}
-test "Look copies its operand before releasing pointer selection storage" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
- defer p.deinit();
- const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n");
- const operand = try p.gpa.dupe(u8, "/test.txt:3:2");
- pane.sel[0] = .{ .state = .done, .source_id = 1 };
- pane.pointer_selections[0] = .{
- .gesture = pane.sel[0],
- .rows = try p.gpa.alloc(Pane.PointerRow, 0),
- .raw_text = operand,
- };
- p.drag = .{ .select = .{ .id = 0, .button = config.select_button } };
- p.lookAt(0, operand);
- try std.testing.expectEqual(@as(i32, 2), pane.cur_row);
- try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
- try std.testing.expect(pane.pointer_selections[0] == null);
- try std.testing.expectEqual(.none, pane.sel[0].state);
- try std.testing.expectEqual(.none, p.drag);
-}
-
-test "Look ignores missing and out of bounds pane addresses" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
- defer p.deinit();
- const pane = try p.setTestFile("alpha\nbravo\n");
- for ([_][]const u8{ config.pane_addr ++ "15:2", config.pane_addr ++ "999999:2" }) |operand| {
- pane.setRange(pane.file.?.content, 0, .{ .anchor = 0, .head = 3 }, true);
- p.lookAt(0, operand);
- try std.testing.expectEqual(@as(usize, 0), p.active);
- try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
- try std.testing.expect(!pane.vsel.active);
- }
-}
-
test "jump history clears selections and lands at the recorded cursor" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
defer p.deinit();
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
index 106252a0..9b623183 100644
--- a/src/pdf_view.zig
+++ b/src/pdf_view.zig
@@ -1962,7 +1962,7 @@ fn refreshCleanSections(core: *pardes.Pardes, state: *State) void {
core.gpa.free(content);
return;
}
- core.invalidateLookHover(remembered.pane);
+ look.invalidateLookHover(core, remembered.pane);
File.setContent(core, file, content);
const rows = File.lineCount(file.content);
file.scroll = @min(file.scroll, rows - 1);
@@ -2009,7 +2009,7 @@ fn rearmCleanSections(
result.nsel = 0;
pane.search_pane = remembered.pane;
pane.search_row = null;
- core.armLookWalk(remembered.pane);
+ look.armLookWalk(core, remembered.pane);
core.active = id;
return true;
}
@@ -2049,7 +2049,7 @@ pub fn openSections(core: *pardes.Pardes, id: usize) void {
core.active = id;
pane.search_pane = free;
pane.search_row = null;
- core.armLookWalk(free);
+ look.armLookWalk(core, free);
}
const SectionsOwner = struct {
@@ -2098,7 +2098,7 @@ pub fn lookSection(
const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true;
switch (destination) {
.internal => |internal| {
- core.clearNavigationSelection(owner.pane);
+ look.clearNavigationSelection(core, owner.pane);
revealOutlineDestination(core, owner.pane, internal);
},
.external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }),
@@ -2282,7 +2282,7 @@ pub fn probeAt(
const state = &(pane.pdf orelse return null);
if (state.document.linkAt(core.pdf_gpa, page, point) catch null) |found| {
var link = found;
- if (core.pdfLinkLocation(pane, link) != null) {
+ if (look.pdfLinkLocation(core, pane, link) != null) {
const quads = core.pdf_gpa.alloc(pdf.Quad, 1) catch {
link.deinit(core.pdf_gpa);
return null;
diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig
index ec94b509..470615c9 100644
--- a/test/fs_namespace.zig
+++ b/test/fs_namespace.zig
@@ -224,7 +224,7 @@ test "virtual file opens do not request OS watches" {
test "Look at a virtual file opens one pane and failed opens release their slot" {
const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
- p.lookAt(0, "/virtual/index");
+ pardes.look.lookAt(p, 0, "/virtual/index");
var opened: usize = 0;
for (p.panes) |slot| {
const pane = slot orelse continue;
diff --git a/test/output.zig b/test/output.zig
index ebea79d4..b4c018db 100644
--- a/test/output.zig
+++ b/test/output.zig
@@ -610,7 +610,7 @@ test "EffectCode lists readable virtual sources used by this backend" {
}
try std.testing.expect(count >= 3);
const report = p.active;
- p.lookAt(report, backend_source);
+ pardes.look.lookAt(p, report, backend_source);
try std.testing.expect(p.active != report);
const source = p.panes[p.active].?.file.?;
try std.testing.expectEqualStrings(backend_source, source.path);
@@ -722,7 +722,7 @@ test "n/N in ordinary text skips comment slashes and retains real paths" {
defer p.deinit();
const pane = try p.setTestFile("// ordinary comment\n/// documentation comment\n(//) [///] punctuation\n/\n// /tmp\n//tmp\n");
try std.testing.expect(pane.file.?.output == null);
- p.armLookWalk(0);
+ pardes.look.armLookWalk(p, 0);
const available = p.freeSlot();
for ([_]struct { key: u21, row: i32, text: []const u8 }{
.{ .key = 'n', .row = 3, .text = "/" },
@@ -796,7 +796,7 @@ test "n/N skips formatted context and selects comment match locations" {
try p.dumpState();
const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?);
defer restored.deinit();
- restored.armLookWalk(id);
+ pardes.look.armLookWalk(restored, id);
const restored_result = restored.panes[id].?;
restored.update(.{ .key = .{ .cp = 'n' } });
try std.testing.expectEqual(@as(i32, @intCast(matches[0])), restored_result.cur_row);
@@ -1562,7 +1562,7 @@ test "stacked search and LSP results retain source tint navigation and dump owne
try std.testing.expectEqual(@intFromEnum(pardes.syntax.Syn.number), file.highlights[number]);
p.active = id;
pane.mode = .normal;
- p.armLookWalk(id);
+ pardes.look.armLookWalk(p, id);
for ([_]struct { key: u21, match: usize }{ .{ .key = 'n', .match = 0 }, .{ .key = 'n', .match = 1 }, .{ .key = 'N', .match = 0 } }) |step| {
p.update(.{ .key = .{ .cp = step.key } });
try std.testing.expectEqual(@as(i32, @intCast(matches[step.match])), pane.cur_row);
@@ -1585,7 +1585,7 @@ test "stacked search and LSP results retain source tint navigation and dump owne
try std.testing.expectEqualSlices(u8, before.colors, after.colors);
}
restored.active = id;
- restored.armLookWalk(id);
+ pardes.look.armLookWalk(restored, id);
restored.update(.{ .key = .{ .cp = 'N' } });
try std.testing.expectEqual(@as(i32, @intCast(matches[1])), restored.panes[id].?.cur_row);
try std.testing.expectEqualStrings(source_text, source.file.?.content);
diff --git a/test/panes.zig b/test/panes.zig
index e865641f..f45d42b2 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -494,7 +494,7 @@ const TreeContextTests = struct {
defer frame.deinit();
_ = try p.render(frame.allocator());
try std.testing.expectEqual(@as(i32, 0), pane.scroll());
- p.lookAt(0, "/tree-context.zig:60");
+ pardes.look.lookAt(p, 0, "/tree-context.zig:60");
try std.testing.expectEqual(@as(usize, 0), p.active);
try std.testing.expectEqual(@as(i32, 59), pane.cur_row);
const surface = try p.render(frame.allocator());
@@ -4469,7 +4469,7 @@ const LookResetTests = struct {
const pane = try p.setTestFile("alpha\nbravo\ncharlie\nneedle\n");
try seed(pane, "/test.txt:3:2");
// This operand is released by Look's selection cleanup.
- p.lookAt(0, pane.pointer_selections[0].?.raw_text.?);
+ pardes.look.lookAt(p, 0, pane.pointer_selections[0].?.raw_text.?);
try cleared(pane);
try std.testing.expectEqual(@as(usize, 0), p.active);
try std.testing.expectEqual(@as(i32, 2), pane.cur_row);
@@ -4477,7 +4477,7 @@ const LookResetTests = struct {
try std.testing.expect(!pane.vsel.active);
try seed(pane, "/test.txt:2:2-4");
- p.lookAt(0, pane.pointer_selections[0].?.raw_text.?);
+ pardes.look.lookAt(p, 0, pane.pointer_selections[0].?.raw_text.?);
try cleared(pane);
try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
try std.testing.expectEqualStrings("rav", selected(pane));
@@ -4485,7 +4485,7 @@ const LookResetTests = struct {
p.gpa.free(pane.file.?.path);
pane.file.?.path = try p.gpa.dupe(u8, "/tmp/look-selection-source.txt");
try seed(pane, "needle");
- p.lookAt(0, pane.pointer_selections[0].?.raw_text.?);
+ pardes.look.lookAt(p, 0, pane.pointer_selections[0].?.raw_text.?);
try cleared(pane);
try std.testing.expectEqual(@as(i32, 3), pane.cur_row);
try std.testing.expectEqualStrings("needle", selected(pane));
@@ -4508,7 +4508,7 @@ const LookResetTests = struct {
p.active = 0;
try seed(source, if (range) "/look-destination.txt:2:2-4" else "/look-destination.txt:3:2");
try seed(destination, "previous destination");
- p.lookAt(0, source.pointer_selections[0].?.raw_text.?);
+ pardes.look.lookAt(p, 0, source.pointer_selections[0].?.raw_text.?);
try std.testing.expectEqual(id, p.active);
try cleared(source);
try cleared(destination);
diff --git a/test/pdf.zig b/test/pdf.zig
index 25d0de76..69a82123 100644
--- a/test/pdf.zig
+++ b/test/pdf.zig
@@ -135,9 +135,9 @@ const PdfLinkTests = struct {
const choices_id = for (p.panes, 0..) |slot, id| {
if (slot == choices) break id;
} else return error.MissingPdfLinkChoices;
- p.lookAt(choices_id, "https://example.com/embedded");
+ pardes.look.lookAt(p, choices_id, "https://example.com/embedded");
try expectOpened(p, "https://example.com/embedded");
- p.lookAt(choices_id, "target.txt");
+ pardes.look.lookAt(p, choices_id, "target.txt");
try std.testing.expectEqualStrings("chosen file\n", p.panes[p.active].?.file.?.content);
p.active = 0;
_ = try p.render(frame.allocator());
@@ -349,16 +349,16 @@ test "PdfSections Look follows the exact owning PDF, not an equal path" {
const first_row = std.mem.sliceTo(first_output.file.?.content, '\n');
const target = first_row[0 .. std.mem.indexOfScalar(u8, first_row, ' ') orelse first_row.len];
- p.lookAt(first_output_id, target);
+ pardes.look.lookAt(p, first_output_id, target);
try std.testing.expectEqual(@as(usize, 0), p.active);
- p.lookAt(duplicate_output_id, target);
+ pardes.look.lookAt(p, duplicate_output_id, target);
try std.testing.expectEqual(duplicate_id, p.active);
// Once the original document is gone, its output cannot reinterpret an
// old ordinal against the still-open equal-path duplicate.
try std.testing.expect(pardes.test_api.runBuiltin(p, "Del", 0, "", null));
p.active = first_output_id;
- p.lookAt(first_output_id, target);
+ pardes.look.lookAt(p, first_output_id, target);
try std.testing.expectEqual(first_output_id, p.active);
}
@@ -881,7 +881,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll);
const revision_before_search = active_raster.revision;
- try p.runSearch(0, "Pardes", .text, .top);
+ try pardes.look.runSearch(p, 0, "Pardes", .text, .top);
try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
try std.testing.expectEqual(revision_before_search, active_raster.revision);
const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
@@ -1578,7 +1578,7 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" {
p.locations_config.context = 2;
p.locations_config.tscontext = true;
- try p.runSearch(0, "Pardes", .text, .top);
+ try pardes.look.runSearch(p, 0, "Pardes", .text, .top);
const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
const results = p.panes[results_id].?.file.?;
var found: [3]bool = @splat(false);
diff --git a/test/perf.zig b/test/perf.zig
index 7d3b5e3d..6ce8f014 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -445,7 +445,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
const core = try boot();
defer core.deinit();
const t0 = nowNs();
- core.lookAt(0, path);
+ pardes.look.lookAt(core, 0, path);
pump(core);
_ = try frame(core);
const dt = nowNs() -| t0;
@@ -459,7 +459,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
const core = try boot();
defer core.deinit();
- core.lookAt(0, path);
+ pardes.look.lookAt(core, 0, path);
pump(core);
const id = filePane(core) orelse return error.OpenDidNotOpenFile;
core.active = id;