summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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;