From 82551f77833a09b313bd3e58371582abd1aedcc4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 22:27:42 -0300 Subject: Move looking out of pardes.zig into look.zig Pure move, no behaviour change (acme keeps this in look.c): expanding the word under a click (ExpandedWord, expandedWord, expandedSel, cursorWordSel), the n/N walk (LookFrom, lookEdge, lookStand, lookWalkPanes, lookPast, wholeRowSpan, lookSpanIn, lookWalk, landLookSpot, noteLookSource, armLookWalk), search results (Search, SearchStart, submitSearch, lookFirstHit, runSearch, searchStep, jumpResult), the look-hover preview (LookHoverWait, LookHoverPreview, FileWordSpan, PdfWordPreview, invalidateLookHover, cancelLookHover, lookHoverPane, noteLookHover, refreshLookHoverFromRaw, advanceLookHover) and lookAt with its targets (focusPaneLine, selectSpan, openPaneTarget, focusPaneByPath, clearNavigationSelection, resolveLookTarget, locationText, canonicalLookLocation, pdfLinkLocation, followPdfLink), with five tests, go verbatim to the end of look.zig after its word and target resolution. The methods become free functions taking `p: *Pardes`; their 143 call sites change from `p.lookAt(..)` to `look.lookAt(p, ..)` (tests reach them as `pardes.look.x`). Inside look.zig the moved code's `look.` prefix drops. Co-Authored-By: Claude Opus 5.5 --- src/look.zig | 1101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1101 insertions(+) (limited to 'src/look.zig') 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); + } +} -- cgit v1.3