const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); const exec = @import("exec.zig"); 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 animation = @import("animation.zig"); const modal = @import("modal.zig"); const tagline = @import("tagline.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 tag_layer = @import("tag_layer.zig"); const TagHit = pardes.TagHit; 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; extern "c" fn _exit(status: c_int) noreturn; const opener_path: ?[*:0]const u8 = switch (builtin.os.tag) { .linux => "/usr/bin/xdg-open", .macos => "/usr/bin/open", else => null, }; pub fn openLink(url: []const u8) void { const opener = opener_path orelse return; var buf: [1024]u8 = undefined; const url_z = std.fmt.bufPrintSentinel(&buf, "{s}", .{url}, 0) catch return; const pid = fork(); if (pid < 0) return; if (pid == 0) { if (fork() == 0) { @import("host_io.zig").resetChildSignals(); const argv: [3:null]?[*:0]const u8 = .{ opener, url_z.ptr, null }; _ = execv(opener, &argv); } _exit(0); } _ = libc.waitpid(pid, null, 0); } pub const Spot = struct { line: usize = 0, col: usize = 0, end_line: usize = 0, end_col: usize = 0, }; fn num(tok: []const u8, i: usize) struct { v: usize, end: usize } { var v: usize = 0; var j = i; while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) v = v *| 10 +| (tok[j] - '0'); return .{ .v = v, .end = j }; } pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: usize } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { if (tok[sep] != config.line_col_sep) continue; const l = num(tok, sep + 1); if (l.end == sep + 1) continue; // no digits after ':' const path = tok[0..sep]; var i = l.end; if (i < tok.len and tok[i] == config.range_sep) { const e = num(tok, i + 1); if (e.end == i + 1) continue; // a dash with no number is not a range if (e.end < tok.len and tok[e.end] != config.line_col_sep) continue; // junk after it return .{ .path = path, .at = .{ .line = l.v, .end_line = e.v }, .end = e.end }; } if (i < tok.len and tok[i] != config.line_col_sep) continue; // junk after the number var at: Spot = .{ .line = l.v }; if (i == tok.len) return .{ .path = path, .at = at, .end = i }; const c = num(tok, i + 1); if (c.end == i + 1) return .{ .path = path, .at = at, .end = i }; if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep) return .{ .path = path, .at = at, .end = i }; at.col = c.v; i = c.end; if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at, .end = i }; const e = num(tok, i + 1); if (e.end == i + 1) return .{ .path = path, .at = at, .end = i }; at.end_line = at.line; at.end_col = e.v; var end = e.end; if (e.end < tok.len and tok[e.end] == config.line_col_sep) { const e2 = num(tok, e.end + 1); if (e2.end > e.end + 1) { at.end_line = at.end_col; at.end_col = e2.v; end = e2.end; } } return .{ .path = path, .at = at, .end = end }; } return .{ .path = tok, .at = .{}, .end = tok.len }; } test "parsePathLine: spots, ranges, and the paths that merely look like them" { const cases = [_]struct { tok: []const u8, path: []const u8, at: Spot }{ .{ .tok = "main.zig", .path = "main.zig", .at = .{} }, .{ .tok = "main.zig:100", .path = "main.zig", .at = .{ .line = 100 } }, .{ .tok = "main.zig:100:", .path = "main.zig", .at = .{ .line = 100 } }, .{ .tok = "main.zig:100:7", .path = "main.zig", .at = .{ .line = 100, .col = 7 } }, .{ .tok = "main.zig:100-104", .path = "main.zig", .at = .{ .line = 100, .end_line = 104 } }, .{ .tok = "main.zig:100:7-21", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 100, .end_col = 21 } }, .{ .tok = "main.zig:100:7-104:3", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 104, .end_col = 3 } }, .{ .tok = "my-file.zig", .path = "my-file.zig", .at = .{} }, .{ .tok = "my-file:10", .path = "my-file", .at = .{ .line = 10 } }, .{ .tok = "x:1-y", .path = "x:1-y", .at = .{} }, .{ .tok = "a-b-c", .path = "a-b-c", .at = .{} }, .{ .tok = "2026-07-30", .path = "2026-07-30", .at = .{} }, .{ .tok = "main.zig:100x", .path = "main.zig:100x", .at = .{} }, .{ .tok = "main.zig:100:7x", .path = "main.zig", .at = .{ .line = 100 } }, }; for (cases) |c| { const got = parsePathLine(c.tok); try std.testing.expectEqualStrings(c.path, got.path); try std.testing.expectEqual(c.at, got.at); } } test "a number too big to be a line saturates instead of taking the editor down" { const huge = "99999999999999999999999999"; const cases = [_][]const u8{ "f.zig:" ++ huge, "f.zig:" ++ huge ++ ":" ++ huge, "f.zig:1-" ++ huge, "f.zig:1:2-" ++ huge ++ ":" ++ huge, }; for (cases) |tok| { const got = parsePathLine(tok); try std.testing.expectEqualStrings("f.zig", got.path); try std.testing.expect(got.at.line >= 1); } var realbuf: [4096]u8 = undefined; const target = resolve(null, "@p" ++ huge, "/tmp", &realbuf); try std.testing.expect(target == .pane); try std.testing.expect(target.pane.id >= 16); } test "parsePathLine: `end` separates a whole-token target from a lenient read" { for ([_][]const u8{ "main.zig", "main.zig:100", "main.zig:100:7", "main.zig:100-104", "main.zig:100:7-21", "main.zig:100:7-104:3", "@p3:10:5", "x:1-y", }) |tok| try std.testing.expectEqual(tok.len, parsePathLine(tok).end); const partial = [_]struct { tok: []const u8, end: usize }{ .{ .tok = "main.zig:100:", .end = "main.zig:100".len }, // trailing ':' is peeled, not parsed .{ .tok = "main.zig:100:7x", .end = "main.zig:100".len }, .{ .tok = "main.zig:100:7 fn main() void {", .end = "main.zig:100".len }, .{ .tok = "main.zig:100:7-21 const x = 1;", .end = "main.zig:100:7-21".len }, .{ .tok = "@p3:10:5 /home/goblin", .end = "@p3:10".len }, }; for (partial) |c| try std.testing.expectEqual(c.end, parsePathLine(c.tok).end); const whole = "main.zig:100-104 whole lines"; try std.testing.expectEqual(whole.len, parsePathLine(whole).end); try std.testing.expectEqualStrings(whole, parsePathLine(whole).path); } pub const FileKind = if (pdf_enabled) enum { text, pdf } else enum { text }; pub const FileTarget = struct { path: []const u8, at: Spot, kind: FileKind = .text, }; pub const Target = union(enum) { none, dir: []const u8, // resolved absolute path, in caller's buf file: FileTarget, image: struct { path: []const u8 }, url: []const u8, pane: struct { id: usize, at: Spot }, }; pub fn isImagePath(path: []const u8) bool { for (config.image_exts) |ext| { if (std.ascii.endsWithIgnoreCase(path, ext)) return true; } return false; } pub fn isPdfPath(path: []const u8) bool { if (comptime !pdf_enabled) return false; return std.ascii.endsWithIgnoreCase(path, ".pdf"); } test "PDF file kinds exist only in MuPDF-enabled builds" { try std.testing.expectEqual(pdf_enabled, isPdfPath("manual.PDF")); try std.testing.expect(!isPdfPath("manual.pdf.txt")); try std.testing.expectEqual( pdf_enabled, std.meta.stringToEnum(FileKind, "pdf") != null, ); try std.testing.expect(std.meta.stringToEnum(std.meta.Tag(Target), "pdf") == null); } test ".pdf Look paths are ordinary files when MuPDF is disabled" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; const target = resolve(null, "docs/design.pdf", ".", &realbuf); switch (target) { .file => |file| { if (comptime pdf_enabled) try std.testing.expectEqual(FileKind.pdf, file.kind) else try std.testing.expectEqual(FileKind.text, file.kind); }, else => return error.PdfDidNotResolveAsFile, } } pub const Span = struct { start: usize, end: usize }; const lead_trim = "([{<\"'`*"; const trail_trim = ")]}>\"'`*,;:.!?"; pub fn wordSpan(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { if (word.len == 0) return null; var lo: usize = 0; var hi: usize = word.len; while (lo < hi and std.mem.indexOfScalar(u8, lead_trim, word[lo]) != null) lo += 1; while (hi > lo and std.mem.indexOfScalar(u8, trail_trim, word[hi - 1]) != null) hi -= 1; if (lo < hi and resolve(p, word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi }; if (lo == 0 and hi == word.len) return null; if (resolve(p, word, cwd, realbuf) == .none) return null; return .{ .start = 0, .end = word.len }; } test "lookableSpan peels prose punctuation off a path, largest first" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig".len }), wordSpan(null, "src/look.zig", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 1, .end = 1 + "src/look.zig".len }), wordSpan(null, "(src/look.zig),", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:3".len }), wordSpan(null, "src/look.zig:12:3,", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12".len }), wordSpan(null, "src/look.zig:12:", ".", &realbuf), ); try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "nothing-here", ".", &realbuf)); try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "", ".", &realbuf)); try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "((()))", ".", &realbuf)); } pub fn lineSpan(p: ?*pardes.Pardes, line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { var lo: usize = 0; while (lo < line.len and (line[lo] == ' ' or line[lo] == '\t')) lo += 1; var hi = std.mem.trimEnd(u8, line, " \t\r").len; while (hi > lo) { var a = lo; var b = hi; while (a < b and std.mem.indexOfScalar(u8, lead_trim, line[a]) != null) a += 1; while (b > a and std.mem.indexOfScalar(u8, trail_trim, line[b - 1]) != null) b -= 1; const cand = line[a..b]; if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(p, cand, cwd, realbuf)) { .dir, .file, .image => return .{ .start = a, .end = b }, .url, .pane => if (std.mem.indexOfAny(u8, cand, " \t") == null) return .{ .start = a, .end = b }, .none => {}, }; while (hi > lo and line[hi - 1] != ' ' and line[hi - 1] != '\t') hi -= 1; while (hi > lo and (line[hi - 1] == ' ' or line[hi - 1] == '\t')) hi -= 1; } return null; } test "lookableLineSpan takes the row's location and stops before its text" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5-9".len }), lineSpan(null, "src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5".len }), lineSpan(null, "src/look.zig:12:5 pub fn resolve", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "@p3:10:5".len }), lineSpan(null, "@p3:10:5 /home/goblin", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 3, .end = 3 + "src/look.zig".len }), lineSpan(null, " (src/look.zig)", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "https://pardes.dev/a".len }), lineSpan(null, "https://pardes.dev/a Chapter One", ".", &realbuf), ); try std.testing.expectEqual( @as(?Span, null), lineSpan(null, "see also src/look.zig", ".", &realbuf), ); try std.testing.expectEqual(@as(?Span, null), lineSpan(null, " ", ".", &realbuf)); try std.testing.expectEqual(@as(?Span, null), lineSpan(null, "", ".", &realbuf)); } test "lookableLineSpan prefers the longest run, so a blank inside a path is one span" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const name = "pardes look span.txt"; try tmp.dir.writeFile(io, .{ .sub_path = name, .data = "" }); try tmp.dir.createDir(io, "subdir", .default_dir); var path: [4096]u8 = undefined; const len = try tmp.dir.realPathFile(io, name, &path); const cwd = std.fs.path.dirname(path[0..len]).?; try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = name.len }), lineSpan(null, name, cwd, &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "subdir".len }), lineSpan(null, "subdir holds pardes look span.txt", cwd, &realbuf), ); } pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { const trimmed = std.mem.trim(u8, word_raw, " \t\r\n"); const pl = parsePathLine(trimmed); const word = pl.path; if (word.len == 0) return .none; // POSIX resolves repeated slashes to the root directory, but bare // and // /// in text are comment markers. A single / remains a valid target. if (word.len > 1 and std.mem.indexOfNone(u8, word, "/") == null) return .none; if (word.len > config.pane_addr.len and std.mem.startsWith(u8, word, config.pane_addr)) { var id: usize = 0; for (word[config.pane_addr.len..]) |c| { if (!std.ascii.isDigit(c)) break; id = id *| 10 +| (c - '0'); } else { // `@p`: the pane's serial, as /index and the log name // it, not its slot; one that is not open names none. const slot = if (p) |pp| (pp.paneBySerial(@intCast(@min(id, std.math.maxInt(u32)))) orelse MAX_PANES) else id; return .{ .pane = .{ .id = slot, .at = pl.at } }; } } for (config.url_schemes) |scheme| { if (std.mem.startsWith(u8, trimmed, scheme)) return .{ .url = trimmed }; } const found = fs.resolve(p, word, cwd, realbuf) orelse return .none; if (found.dir) return .{ .dir = found.path }; if (comptime pdf_enabled) if (isPdfPath(found.path)) return .{ .file = .{ .path = found.path, .at = pl.at, .kind = .pdf, } }; 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; if (sl.r0 >= 0 and sl.r0 < pane.tag_rows) return tagWordSel(p, pane, 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; } /// A tag word is expanded in the tag's own text, so a word the wrapping /// broke across rows is still one word, as acme's frame wraps a tag /// without breaking its text. fn tagWordSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { const arena = p.scratch.allocator(); const shown = tagline.shownTag(p, arena, pane) catch return null; const whole = tagline.tagText(p, arena, pane) catch return null; const row: usize = @as(usize, @intCast(at.r0)) + pane.tag_top; const shown_line = modal.lineSlice(shown.text, row); const real = shown.toReal(modal.lineStartOffset(shown.text, row) + panes.File.rawAtDisplay(shown_line, @intCast(@max(0, at.c0)))); const start = if (std.mem.lastIndexOfScalar(u8, whole[0..@min(real, whole.len)], '\n')) |nl| nl + 1 else 0; const end = std.mem.indexOfScalarPos(u8, whole, start, '\n') orelse whole.len; const b = expandedWord(whole[start..end], real - start) orelse return null; const lo = shownCell(shown.text, shown.fromReal(start + b.lo), pane.tag_top); const hi = shownCell(shown.text, shown.fromReal(start + b.hi - 1), pane.tag_top); return .{ .state = at.state, .r0 = lo.row, .c0 = lo.col, .r1 = hi.row, .c1 = hi.col }; } fn shownCell(text: []const u8, off: usize, top: usize) struct { row: i32, col: i32 } { const before = text[0..@min(off, text.len)]; const row = std.mem.count(u8, before, "\n"); const line_start = if (std.mem.lastIndexOfScalar(u8, before, '\n')) |nl| nl + 1 else 0; return .{ .row = @as(i32, @intCast(row)) - @as(i32, @intCast(top)), .col = @intCast(panes.File.rawDisplayCol(text[line_start..], off - line_start)), }; } /// 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.body.cur_row, pane.body.cur_col); const vrow = w.row + @as(i32, pane.tag_rows); const vcol = if (pane.file != null) panes.File.displayOffset(pane, pane.body.cur_row, w.at, pane.body.cur_col) + @as(i32, panes.File.gutterWidth(pane)) else blk: { const lines = edit.paneCursorLines(p, &pane.body) catch break :blk pane.body.cur_col; const local = pane.body.cur_row; if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.body.cur_col; break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.body.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| { // helix keeps the pattern in `/` if (a.pat.len > 0) p.registers.put(p.gpa, '/', a.pat, 0, 1, true); return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode); } 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, true) 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.body.vsel.active = false; rp.body.msel.active = false; rp.body.select = false; exec.runBuiltin(p, config.look_cmd, rid, "", txt); } /// Fills the results buffer, and answers how many rows a text or PDF /// search found (0 for Find and Grep, which do not count). With /// `open_empty` false a text search that found nothing opens no buffer. pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart, open_empty: bool) !usize { const pane = p.panes[id] orelse return 0; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return 0; 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; // A pane of the served tree (/virtual/, /n//) has no // directory on this host to walk, and one that named `/` would // swallow every other root: passed over. const d = filesystem.localPath(Pardes.paneDir(pp)) orelse continue; if (filesystem.isVirtual(if (pp.file) |f| f.path else d)) continue; 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; } // A pane's directory that is not there (a scratch named into one // not made yet, a peer's) has nothing to grep: passed over, not the // search's failure. for (roots[0..nroots]) |r| out_len += filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]) catch |err| switch (err) { error.FileNotFound, error.NotDir, error.AccessDenied => continue, else => return err, }; nrows = std.mem.count(u8, out[0..out_len], "\n"); } else if (kind == .find) { out_len = try filesystem.find(arena, dir, pat, out); nrows = std.mem.count(u8, out[0..out_len], "\n"); } 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.body); // 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}", .{pane.serial}) catch return error.PathTooLong; const cl: usize = @intCast(@max(0, pane.body.cur_row)); const cc: usize = @intCast(@max(0, pane.body.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; } } // A Find or Grep that finds nothing empties its directory's +Search, if // there is one, rather than leave the last search's rows looking like // this one's. if (!open_empty and nrows == 0 and (kind == .text or !panes.Output.hasFileSearch(p, dir))) return 0; 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); return nrows; } 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.body.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.body.vsel.active = false; rp.body.nsel = 0; rp.body.cur_row = r; rp.body.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.body.cur_col = @intCast(selected.start); rp.look_at = .{ .row = r, .col0 = @intCast(selected.start), .col1 = @intCast(selected.end - 1), }; } else { rp.body.cur_col = 0; rp.look_at = null; } if (tr.commands) { exec.runBuiltin(p, 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.body.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.body.cur_row and s.col0 == pane.body.cur_col) return .{ .row = s.row, .col = s.col0, .strict = true }; // A selection on one row (a Restore brings the dot back as one, with no // walk remembered) is where the walk stands: from its start, past it // either way, as from a spot the walk landed on. const v = pane.body.vsel; if (v.active and v.explicit and v.row == pane.body.cur_row and v.col != pane.body.cur_col) return .{ .row = v.row, .col = @min(v.col, pane.body.cur_col), .strict = true }; return .{ .row = pane.body.cur_row, .col = pane.body.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.body) 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.body); // fresh out of tty mode the cursor still tracks the shell pane.body.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true }; pane.body.msel.active = false; pane.body.nsel = 0; pane.body.cur_row = spot.row; pane.body.cur_col = spot.col0; pane.body.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 tag_layer.sameCell(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 tag_layer.sameCell(waiting.tag_hit, tag_hit)) return; // Found nothing to open here a moment ago: the pointer resting on it // does not start another wait (and another frame) every frame. if (p.look_hover_refused) |refused| if (refused.col == col and refused.row == row and refused.pane == id and refused.serial == pane.serial and mouse.sameBodyCell(refused.body_hit, body_hit) and tag_layer.sameCell(refused.tag_hit, tag_hit)) return; p.look_hover_refused = null; 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 { p.look_hover_refused = waiting.*; 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 = exec.pointerOperand(p, pane, pointed.sel); if (operand.text == null or operand.text.?.len == 0) { p.look_hover_refused = waiting.*; 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.body.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) { // center, clamp at top; a text that fits is shown whole const next = if (pane.textFits()) 0 else (at.line - 1) -| pane.rows / 2; if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } } pane.body.cur_row = @intCast(at.line - 1); pane.body.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0; pane.body.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.body) 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 = panes.Text.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); pane.body.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.body.vsel = .{}; pane.body.msel = .{}; pane.body.nsel = 0; pane.body.select = false; pane.body.normal.clear(); pane.body.restore_cursor = false; pane.body.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); // `.`, `..`, `./x` are this pane's directory's, not another's. if (found != .none or pl.path.len == 0 or pl.path[0] == '/' or filesystem.dotRelative(pl.path)) 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}", .{pane.serial}) 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 => {}, } } /// A look that found nothing: said on the message row, logged as an `err` /// record, and marked so that /look reads back nothing (ninep/ctl.zig run). /// The most of a looked-at path or text a miss repeats; past it, cut on a /// character boundary and ended with `…`. const miss_path_cap = 256; fn missText(text: []const u8, buf: *[miss_path_cap + "…".len]u8) []const u8 { if (text.len <= miss_path_cap) return text; var n: usize = miss_path_cap; while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1; @memcpy(buf[0..n], text[0..n]); @memcpy(buf[n..][0.."…".len], "…"); return buf[0 .. n + "…".len]; } fn missedLine(p: *Pardes, id: usize, path: []const u8, line: anytype) void { var cut: [miss_path_cap + "…".len]u8 = undefined; missed(p, id, "{s} has no line {d}", .{ missText(path, &cut), line }); } fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void { p.look_missed = true; var said: [miss_path_cap + 64]u8 = undefined; const why = std.fmt.bufPrint(&said, fmt, args) catch "no match"; var msg: [said.len + 8]u8 = undefined; // Shown on the pane; logged once, as the err below, not a msg too. p.fs.unlogged = true; p.setMessage(id, std.fmt.bufPrint(&msg, "Look: {s}", .{why}) catch "Look: no match"); p.fs.unlogged = false; const serial = if (p.panes[id]) |pane| pane.serial else 0; var rec: [said.len + 32]u8 = undefined; pardes.ctlfs.events.noteErrCounted(p, std.fmt.bufPrint(&rec, "{d} look: {s}", .{ serial, why }) catch return); } /// After a look at `path:N` focused its pane: a line past the end focuses /// nothing, leaves no pane it opened, and is a miss; a bare line number /// selects the line, its newline included, as acme's does. fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize, opened: ?usize) void { const shown = p.panes[p.active] orelse return; const f = if (shown.file) |*file| file else return; if (at.line > panes.File.nlines(p.gpa, f)) { if (opened) |free| p.removePane(free, null) catch {}; if (p.panes[before] != null) p.active = before; return missedLine(p, id, path, at.line); } if (at.line > 0 and at.col == 0 and at.end_line == 0) { const start = modal.lineStartOffset(f.content, at.line - 1); const next = std.mem.indexOfScalarPos(u8, f.content, start, '\n'); const end = if (next) |i| i + 1 else f.content.len; @import("ninep/pane.zig").setDot(shown, .{ .q0 = @intCast(start), .q1 = @intCast(end) }); } } /// A plain word looked at in a pane, as acme's look3 does with one that /// names no file (look.c:210, search): its next place in that pane after /// the dot, wrapping to the top, is selected there. No +Search pane /// (`LookWord list` lists every place in one instead). fn lookWord(p: *Pardes, id: usize, pane: *Pane, word: []const u8) void { const ninep_pane = @import("ninep/pane.zig"); const f = pane.file orelse return; const text = f.content; const from: usize = @min(ninep_pane.dotOf(pane).q1, text.len); const at = std.mem.indexOfPos(u8, text, from, word) orelse (std.mem.indexOf(u8, text[0..@min(text.len, from + word.len -| 1)], word) orelse { var cut: [miss_path_cap + "…".len]u8 = undefined; return missed(p, id, "no match for \"{s}\"", .{missText(word, &cut)}); }); clearNavigationSelection(p, pane); ninep_pane.setDot(pane, .{ .q0 = @intCast(at), .q1 = @intCast(at + word.len) }); p.active = id; } /// `file:addr` or `:addr`, acme's look.c:450-543: a colon followed by an /// address character (addr.c:28, isaddrc) that is not a line spot this /// parser already reads (`file:12`, `file:12:5`). Not a URL. fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } { if (std.mem.indexOf(u8, tok, "://") != null) return null; const colon = std.mem.indexOfScalar(u8, tok, ':') orelse return null; const expr = tok[colon + 1 ..]; // A written look is a whole line: the path has no blanks, but a // pattern in the address may (`calc.py:/return a/`). if (std.mem.indexOfAny(u8, tok[0..colon], " \t") != null) return null; if (std.mem.indexOfAny(u8, expr, " \t") != null and std.mem.indexOfAny(u8, expr, "/?") == null) return null; if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/?$.#,;", expr[0]) == null) return null; if (std.ascii.isDigit(expr[0])) { const pl = parsePathLine(tok); if (pl.at.line != 0 and pl.end == tok.len) return null; } return .{ .path = tok[0..colon], .expr = expr }; } /// Opens (or finds) `path` as Look would, `id`'s own pane when it is empty, /// and selects what `expr` addresses there from its dot. An address that /// does not evaluate moves nothing and says why (acme: no jump). fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void { const ninep_pane = @import("ninep/pane.zig"); const Addr = @import("ninep/addr.zig").Addr; var target = id; // A pane already open on the path is evaluated where it stands: Look's // own clean start would collapse its dot (and the looker's) before the // address ever read it, so `f:/re/` again would find the same match. var realbuf: [4096]u8 = undefined; const open: ?usize = if (path.len == 0) null else switch (resolveLookTarget(p, id, path, &realbuf)) { .pane => |t| if (t.id < MAX_PANES and p.panes[t.id] != null) t.id else null, else => null, }; // `@p:
`: that pane, or, not open, a miss said as one. const by_serial = std.mem.startsWith(u8, path, config.pane_addr); if (by_serial and open == null) return missed(p, id, "no pane {s}", .{path[0..@min(path.len, 32)]}); if (open) |o| target = o else if (path.len > 0) { const before = p.active; lookAt(p, id, path); target = p.active; // The path opened nothing (a search, or no such file): Look said so. if (target == before and target != id) return; } const pane = p.panes[target] orelse return; const f = pane.file orelse { if (by_serial and pane.isTerminal()) return lookTerminalAddress(p, id, target, expr); return missed(p, id, "{s} is no text to address", .{path[0..@min(path.len, 64)]}); }; if (!by_serial and path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return; var a: Addr = .{ .text = f.content, .lim = null, .expr = expr }; // A miss names the file it was looked for in, and is that file's pane's. var cut: [miss_path_cap + "…".len]u8 = undefined; const name = missText(f.path, &cut); const r = a.address(ninep_pane.dotOf(pane)) orelse { return missed(p, target, "{s}: {s}", .{ name, a.err }); }; if (a.i < expr.len or r.q0 > r.q1) { return missed(p, target, "{s}: {s}", .{ name, if (r.q0 > r.q1) "addresses out of order" else "bad address syntax" }); } clearNavigationSelection(p, pane); ninep_pane.setDot(pane, r); p.active = target; } /// `@p:
`: any address over the terminal's body as its /// logical lines read (the lines a +Search lists as `@p:`), /// from its cursor; the match is selected there, as a +Search row's is. fn lookTerminalAddress(p: *Pardes, id: usize, target: usize, expr: []const u8) void { const Addr = @import("ninep/addr.zig").Addr; const pane = p.panes[target].?; const lines = edit.paneCursorLines(p, &pane.body) catch return missed(p, id, "{s}", .{"out of memory"}); var text: std.ArrayList(u8) = .empty; const arena = p.scratch.allocator(); const row: usize = @intCast(@max(0, pane.body.cur_row)); var dot: u32 = 0; for (lines, 0..) |ln, i| { if (i == row) dot = @intCast(text.items.len + @min(ln.len, @as(usize, @intCast(@max(0, pane.body.cur_col))))); text.appendSlice(arena, ln) catch return; text.append(arena, '\n') catch return; } var a: Addr = .{ .text = text.items, .lim = null, .expr = expr }; const r = a.address(.{ .q0 = dot, .q1 = dot }) orelse return missed(p, id, "{s}", .{a.err}); if (a.i < expr.len or r.q0 > r.q1) return missed(p, id, "{s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"}); const from = spotAt(text.items, r.q0); var at: Spot = .{ .line = from.line, .col = from.col }; if (r.q1 > r.q0) { // The last byte of the match, 1-based and inclusive, as a row's range. const to = spotAt(text.items, r.q1 - 1); at.end_line = to.line; at.end_col = to.col; } clearNavigationSelection(p, pane); focusPaneLine(p, target, at, .center); } /// The 1-based line and column of byte `q` of `text`. fn spotAt(text: []const u8, q: u32) struct { line: usize, col: usize } { const upto = text[0..@min(q, text.len)]; const line = std.mem.count(u8, upto, "\n") + 1; const start = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |nl| nl + 1 else 0; return .{ .line = line, .col = upto.len - start + 1 }; } /// A remembered place in `path` (Recent, the jumplist): the file opened or /// focused, then `at` clamped to it as it is now, a line past the end its /// last line, never a miss; a selection's two ends restored where they /// still fit. pub fn reopenAt(p: *Pardes, from: usize, path: []const u8, at: Spot) void { lookAt(p, from, path); const id = p.active; const pane = p.panes[id] orelse return; const f = if (pane.file) |*file| file else return; if (!std.mem.eql(u8, f.path, path)) return; // The empty "line" after a final newline is no line to land on. const n = @max(1, panes.File.nlines(p.gpa, f) - @intFromBool(std.mem.endsWith(u8, f.content, "\n"))); const clamp = struct { fn spot(text: []const u8, lines: usize, line: usize, col: usize) [2]usize { const l = std.math.clamp(line, 1, lines); // Its last character at most, never the newline after it, as // addr's `L:C` clamps. const width = @max(1, modal.lineSlice(text, l - 1).len); return .{ l, std.math.clamp(col, 1, width) }; } }; const head = clamp.spot(f.content, n, at.line, at.col); var to: Spot = .{ .line = head[0], .col = head[1] }; if (at.end_line != 0) { const end = clamp.spot(f.content, n, at.end_line, at.end_col); to.end_line = end[0]; to.end_col = end[1]; } clearNavigationSelection(p, pane); focusPaneLine(p, id, to, .center); // Said when the file no longer has the line, closed or open: the place // is its last line now. if (at.line > n or at.end_line > n) { var said: [miss_path_cap + 64]u8 = undefined; var cut: [miss_path_cap + "…".len]u8 = undefined; p.setMessage(id, std.fmt.bufPrint(&said, "{s} has {d} lines now: at its last", .{ missText(path, &cut), n }) catch "at its last line"); } } /// Whether `pane` is the +Recent or the Jumplist list, whose rows are /// remembered places: a look at one clamps to the file as it is now. fn isRecentList(pane: *const Pane) bool { const f = pane.file orelse return false; const o = f.output orelse return false; return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }) or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist }); } pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const pane = p.panes[id] orelse return; // A +Recent row is a remembered place: reopened and clamped to the file // as it is now, never a miss for a line the file no longer has. if (isRecentList(pane)) { const row = std.mem.trim(u8, operand, " \t\r\n"); const loc = row[0 .. std.mem.indexOfScalar(u8, row, ' ') orelse row.len]; const pl = parsePathLine(loc); if (pl.path.len > 0 and pl.path[0] == '/' and pl.at.line != 0) return reopenAt(p, id, pl.path, pl.at); } p.noteHaptic(.look); noteLookSource(p, id); const command = std.mem.trim(u8, operand, " \t\r\n"); if (config.commandWord(command)) |cmd| { if (exec.execute(p, 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"); // acme's `file:addr` and `:addr` (look.c:450-543): an address after // the colon, evaluated in that file from its dot (openfile, look.c:675). if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr); const pl = parsePathLine(trimmed); if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane); 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); // A line past the end of an open pane is a miss (the target's line is // already clamped, so the one written is asked). if (found == .pane and found.pane.id < MAX_PANES) if (p.panes[found.pane.id]) |shown| { // A terminal's lines are its logical lines, as a +Search lists them. const n = if (shown.file) |*f| panes.File.nlines(p.gpa, f) else if (shown.isTerminal()) if (edit.paneCursorLines(p, &shown.body)) |ls| ls.len else |_| std.math.maxInt(usize) else std.math.maxInt(usize); if (pl.at.line > n) return missedLine(p, id, trimmed, pl.at.line); }; // A miss changes nothing, the selection included; a look that stays in // this pane starts from a clean one. One that goes to another pane // leaves this one's dot alone, as acme's does: only the click's own // highlight goes. if (found == .pane and found.pane.id == id) clearNavigationSelection(p, pane) else if (found != .none) { for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot); pane.sel = @splat(.{}); } switch (found) { .none => { // A `./` or `../` name opened nothing: said, ENOENT, not searched // for as a word. What is missing is the pane's directory when // that is gone, else the file named. if (filesystem.dotRelative(trimmed)) { const dir = Pardes.paneDir(pane); var said: [4200]u8 = undefined; const gone = if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| !exec.isDirectory(local) else false else true; if (gone) return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory"); var cut: [miss_path_cap + "…".len]u8 = undefined; return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such file", .{missText(trimmed, &cut)}) catch "look: no such file"); } // A raw terminal's clicks are its program's; a look written over // 9P is no keyboard input, and searches whatever the key mode. if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return; if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed); const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| { p.reportError(id, "search", err); return; }; if (rows == 0) { var cut: [miss_path_cap + "…".len]u8 = undefined; return missed(p, id, "no match for \"{s}\"", .{missText(trimmed, &cut)}); } clearNavigationSelection(p, pane); 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| { // `@p` names no open pane: a miss, said. if (t.id >= MAX_PANES or p.panes[t.id] == null) return missed(p, id, "no pane {s}", .{pl.path[0..@min(pl.path.len, 32)]}); const target = p.panes[t.id].?; const before = p.active; clearNavigationSelection(p, target); focusPaneLine(p, t.id, t.at, .center); settleLine(p, id, trimmed, t.at, before, null); }, .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // A directory that is not there (the pane's own, gone): said, // ENOENT, no terminal made in it. if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| if (!exec.isDirectory(local)) { var said: [4200]u8 = undefined; return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory"); }; for (p.panes, 0..) |slot, i| { if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and exec.takesCommandLine(p, 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; exec.placeNew(p, id, id, free, .shell); }, .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); // Said with its path and why, as an image that cannot be read is. _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| { var said: [miss_path_cap + 64]u8 = undefined; var cut: [miss_path_cap + "…".len]u8 = undefined; return p.reportError(id, std.fmt.bufPrint(&said, "look: {s}: not a PDF pardes can read", .{missText(target.path, &cut)}) catch "look: not a PDF pardes can read", err); }; exec.placeNew(p, id, id, free, .doc); return; }; // An open pane on this path too short for the line: a miss that // touches nothing. if (openPaneTarget(p, target.path, target.at)) |open| if (p.panes[open.pane.id].?.file) |*f| if (target.at.line > panes.File.nlines(p.gpa, f)) return missedLine(p, id, target.path, target.at.line); // focus an existing pane on this path (rescrolled), else open const before = p.active; var opened: ?usize = null; if (!focusPaneByPath(p, target.path, target.at)) { 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, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1); exec.placeNew(p, id, id, free, .doc); 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; } opened = free; } settleLine(p, id, target.path, target.at, before, opened); }, .image => |target| { if (focusPaneByPath(p, target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const made = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); // One it cannot read (corrupt, truncated, not what its name says) // is said, a failure, rather than shown as a blank pane. if (!@import("image.zig").readable(made.image.?.raw)) { made.vweight = 0; p.unplaced.set(free); var said: [miss_path_cap + 64]u8 = undefined; var cut: [miss_path_cap + "…".len]u8 = undefined; return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: not an image pardes can read", .{missText(target.path, &cut)}) catch "look: not an image pardes can read"); } exec.placeNew(p, id, id, free, .doc); }, } } 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"); pane.tag_expanded = false; // a one-row tag keeps this small screen's rows p.sync(); while (p.nextEffect()) |_| {} const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; _ = try p.newShell(other_id, ""); p.panes[other_id].?.tag_expanded = false; // the blank spot right of the file is its body, not its tag 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.body.cur_row, pane.body.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.body.cur_row, pane.body.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: animation.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; var track: animation.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.body.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.body.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 "@p names a pane by its serial, as /index does" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("alpha\nbravo\n"); var realbuf: [4096]u8 = undefined; var word: [32]u8 = undefined; const target = resolve(p, try std.fmt.bufPrint(&word, config.pane_addr ++ "{d}:2", .{pane.serial}), "/", &realbuf); try std.testing.expectEqual(p.paneBySerial(pane.serial).?, target.pane.id); } 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.body.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.body.cur_row); try std.testing.expect(pane.body.vsel.active); // a miss changes nothing, the selection included } } test "a pointer resting where a look-hover found nothing does not wake the core every frame" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile(" \n" ** 30); p.sync(); const r = p.rects[p.active]; // Resting on blanks in the body: a wait, then nothing to open. p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = r.x + 20, .row = r.y + 5 } }); var frames: usize = 0; while (p.nextWake() != null and frames < 20) : (frames += 1) { p.update(.tick); // What a shell's present does after every frame. p.acknowledgePanelPresentation(&.{}); } try std.testing.expect(frames < 20); try std.testing.expect(p.look_hover_wait == null and p.look_hover_preview == null); }