diff options
Diffstat (limited to 'src/look.zig')
| -rw-r--r-- | src/look.zig | 174 |
1 files changed, 151 insertions, 23 deletions
diff --git a/src/look.zig b/src/look.zig index 17746cd1..4ee5a638 100644 --- a/src/look.zig +++ b/src/look.zig @@ -360,7 +360,12 @@ pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbu for (word[config.pane_addr.len..]) |c| { if (!std.ascii.isDigit(c)) break; id = id *| 10 +| (c - '0'); - } else return .{ .pane = .{ .id = id, .at = pl.at } }; + } else { + // `@p<serial>`: 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| { @@ -635,7 +640,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start 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; + 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| { @@ -748,6 +753,12 @@ fn lookEdge(delta: i32) LookFrom { 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 }; } @@ -1127,7 +1138,7 @@ fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 { 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); + return locationText(p, std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{pane.serial}) catch return null, target.at); }, } } @@ -1175,14 +1186,35 @@ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) v /// 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: [160]u8 = undefined; + var said: [miss_path_cap + 64]u8 = undefined; const why = std.fmt.bufPrint(&said, fmt, args) catch "no match"; - var msg: [176]u8 = undefined; + 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: [200]u8 = undefined; + var rec: [said.len + 32]u8 = undefined; pardes.ctlfs.events.notePath(p, "err", std.fmt.bufPrint(&rec, "{d} look: {s}", .{ serial, why }) catch return); } @@ -1195,7 +1227,7 @@ fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize, 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 missed(p, id, "{s} has no line {d}", .{ path, at.line }); + 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); @@ -1205,6 +1237,24 @@ fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize, } } +/// 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. @@ -1216,7 +1266,7 @@ fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } { // 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 (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; @@ -1231,7 +1281,18 @@ 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; - if (path.len > 0) { + // 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<serial>:<address>`: 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; @@ -1239,8 +1300,11 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void { if (target == before and target != id) return; } const pane = p.panes[target] orelse return; - const f = pane.file orelse return; - if (path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) 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 }; const r = a.address(ninep_pane.dotOf(pane)) orelse { return missed(p, id, "{s}", .{a.err}); @@ -1253,6 +1317,46 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void { p.active = target; } +/// `@p<terminal>:<address>`: any address over the terminal's body as its +/// logical lines read (the lines a +Search lists as `@p<serial>:<line>`), +/// 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 }; +} + pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const pane = p.panes[id] orelse return; p.noteHaptic(.look); @@ -1276,18 +1380,31 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { // 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| if (shown.file) |*f| if (pl.at.line > panes.File.nlines(p.gpa, f)) - return missed(p, id, "{s} has no line {d}", .{ trimmed, pl.at.line }); - // A miss changes nothing, the selection included; anything else starts - // from a clean one. - if (found != .none) clearNavigationSelection(p, pane); + 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 => { - if (pane.image != null or pane.body.mode == .tty) return; + // 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) return missed(p, id, "no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)}); + 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); @@ -1298,8 +1415,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { }, .pane => |t| { - if (t.id >= MAX_PANES) return; - const target = p.panes[t.id] orelse return; + // `@p<serial>` 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); @@ -1333,14 +1451,14 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { // 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 missed(p, id, "{s} has no line {d}", .{ target.path, target.at.line }); + 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, "look", 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); @@ -1358,7 +1476,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { if (focusPaneByPath(p, target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); _ = panes.Image.create(p, free, target.path, &.{}) catch |err| - return p.reportError(id, "look", err); + return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); exec.placeNew(p, id, id, free, .doc); }, } @@ -1587,6 +1705,16 @@ test "Look copies its operand before releasing pointer selection storage" { 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(); @@ -1596,6 +1724,6 @@ test "Look ignores missing and out of bounds pane addresses" { 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); + try std.testing.expect(pane.body.vsel.active); // a miss changes nothing, the selection included } } |
