summaryrefslogtreecommitdiff
path: root/src/look.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/look.zig')
-rw-r--r--src/look.zig174
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
}
}