diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Output.zig | 8 | ||||
| -rw-r--r-- | src/builtins.zig | 11 | ||||
| -rw-r--r-- | src/look.zig | 1101 | ||||
| -rw-r--r-- | src/mouse.zig | 7 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 3 | ||||
| -rw-r--r-- | src/ninep/events.zig | 3 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 3 | ||||
| -rw-r--r-- | src/normal.zig | 14 | ||||
| -rw-r--r-- | src/pardes.zig | 1163 | ||||
| -rw-r--r-- | src/pdf_view.zig | 10 |
10 files changed, 1178 insertions, 1145 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; |
