summaryrefslogtreecommitdiff
path: root/src/look.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/look.zig')
-rw-r--r--src/look.zig203
1 files changed, 167 insertions, 36 deletions
diff --git a/src/look.zig b/src/look.zig
index 2e27a001..d90d27b8 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -10,6 +10,7 @@ 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");
@@ -466,6 +467,7 @@ pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
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;
@@ -481,6 +483,34 @@ pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
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 {
@@ -518,7 +548,7 @@ pub fn submitSearch(p: *Pardes, id: usize) void {
.grep
else
.text;
- runSearch(p, id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err);
+ _ = runSearch(p, id, armed[slash + 1 ..], kind, .top, true) catch |err| return p.reportError(id, "search", err);
if (kind == .text) lookFirstHit(p, id);
}
@@ -538,10 +568,13 @@ fn lookFirstHit(p: *Pardes, id: usize) void {
exec.runBuiltin(p, config.look_cmd, rid, "", txt);
}
-pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void {
- const pane = p.panes[id] orelse return;
+/// 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;
+ 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.
@@ -617,6 +650,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
nrows += 1;
}
}
+ if (!open_empty and kind == .text and nrows == 0) 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
@@ -626,6 +660,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
.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 {
@@ -963,7 +998,8 @@ pub fn focusPaneLine(p: *Pardes, id: usize, at: Spot, landing: enum { center, ke
if (pane.file) |*f| {
if (at.line > panes.File.nlines(p.gpa, f)) return;
if (landing == .center) {
- const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
+ // 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;
@@ -1137,6 +1173,86 @@ 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).
+fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void {
+ p.look_missed = true;
+ var said: [160]u8 = undefined;
+ const why = std.fmt.bufPrint(&said, fmt, args) catch "no match";
+ var msg: [176]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&msg, "Look: {s}", .{why}) catch "Look: no match");
+ const serial = if (p.panes[id]) |pane| pane.serial else 0;
+ var rec: [200]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "err", 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 missed(p, id, "{s} has no line {d}", .{ 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) });
+ }
+}
+
+/// `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;
+ 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 return;
+ if (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});
+ };
+ 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"});
+ }
+ clearNavigationSelection(p, pane);
+ ninep_pane.setDot(pane, r);
+ p.active = target;
+}
+
pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const pane = p.panes[id] orelse return;
p.noteHaptic(.look);
@@ -1149,37 +1265,45 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
// The operand may borrow selected terminal/PDF text released below.
const txt = p.scratch.allocator().dupe(u8, operand) catch return;
const trimmed = std.mem.trim(u8, txt, " \t\r\n");
- clearNavigationSelection(p, pane);
+ // 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| 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);
switch (found) {
.none => {
if (pane.image != null or pane.body.mode == .tty) return;
- runSearch(p, id, trimmed, .text, .cursor) catch |err| {
+ 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)});
+ 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);
}
- // A path that is not there searches for its text, and a search
- // that found nothing says so rather than leave an empty buffer.
- const results = if (pane.search_pane) |rid| p.panes[rid] else null;
- if (results) |r| if (r.file) |f| if (f.content.len == 0) {
- var said: [96]u8 = undefined;
- p.setMessage(id, std.fmt.bufPrint(&said, "Look: no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)}) catch "Look: no match");
- };
+
},
.pane => |t| {
if (t.id >= MAX_PANES) return;
const target = p.panes[t.id] orelse return;
+ 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| {
@@ -1194,43 +1318,48 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err);
nt.greet = true;
- const src = layout.splitParent(p, id);
- const f = layout.findPane(p, src).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ 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);
- const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
+ _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
return p.reportError(id, "look", err);
- exec.placeDoc(p, id, free, nt);
+ 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 missed(p, id, "{s} has no line {d}", .{ target.path, target.at.line });
// focus an existing pane on this path (rescrolled), else open
- if (focusPaneByPath(p, target.path, target.at)) return;
- const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
- return p.reportError(id, "look", err);
- if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
- exec.placeDoc(p, id, free, nt);
- layout.compute(p);
- nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
- if (target.at.end_line != 0) {
- const centered = nt.file.?.scroll;
- selectSpan(p, nt, target.at);
- nt.file.?.scroll = centered;
+ 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);
+ 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 nt = panes.Image.create(p, free, target.path, &.{}) catch |err|
+ _ = panes.Image.create(p, free, target.path, &.{}) catch |err|
return p.reportError(id, "look", err);
- exec.placeDoc(p, id, free, nt);
+ exec.placeNew(p, id, id, free, .doc);
},
}
}
@@ -1244,6 +1373,8 @@ test "Look hover waits without mutating the pane and input cancels it" {
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, "");