summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 11:42:18 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit911215846f1d5a9b2e37a00e74fb9266d4a0884a (patch)
tree31413ca3336cbbd1933d9dc3e24f09a3b8ef46b4 /src
parent0b7a480bef38b209741c520e2478d178767a9e51 (diff)
downloadpardes-911215846f1d5a9b2e37a00e74fb9266d4a0884a.tar.gz
pardes-911215846f1d5a9b2e37a00e74fb9266d4a0884a.zip
a Look path can name a range, and search selects what it found
file:LINE:COL-ENDLINE:ENDCOL, with the two short forms people actually type reading naturally: file:412:9-21 on one line, file:412-418 whole ones. Ends are inclusive. A path feature, not a search feature — a ranged path typed in a tag or middle-clicked out of a shell's output selects just the same; search is only its first consumer. The dash is the fussy part. `-` was already a file char, so a ranged word survives click expansion whole, but a range needs a number on BOTH sides or my-file:10, build-2 and 2026-07-30 would stop being paths. Table-driven test in look.zig for exactly that. Selecting goes through the cellRange/setPaneRange pair the multi-cursor work left, and hxOff clamps both ends, so a stale range selects what still exists rather than crashing or reaching past EOF — pinned with an 8:6-400:9 range in a nine-line file. Producers: / search, Grep, and five LSP sites through a new spanRow — goto, references, rename tokens and both symbol lists were throwing away real protocol ranges at path:line:col. Left alone deliberately: Find rows are bare paths with nothing to span, a jump is a spot not a span, and the diagnostic and format paths only ever have a point, where half a range would be worse than none. One knock-on worth knowing: n now leaves an EXPLICIT selection, so a topbar execute chords it. grep.snap's no-match step was silently becoming `Grep TARGET`; it runs from the leader path now, which never chords, and the dedicated chord steps stayed where they were.
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig2
-rw-r--r--src/config.zig21
-rw-r--r--src/look.zig144
-rw-r--r--src/lsp/lsp.zig36
-rw-r--r--src/lsp/lsp_zls.zig10
-rw-r--r--src/main.zig2
-rw-r--r--src/pardes.zig93
-rw-r--r--src/tutor.txt15
8 files changed, 255 insertions, 68 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 6593be7d..7378e395 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -460,7 +460,7 @@ pub const Last = struct {
while (i > 0) {
i -= 1;
const j = c.p.jumps[i];
- if (j.pane != c.id) return c.p.focusPaneLine(j.pane, j.line, j.col);
+ if (j.pane != c.id) return c.p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col });
}
}
};
diff --git a/src/config.zig b/src/config.zig
index 8f2175f6..2603a40c 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -430,6 +430,27 @@ pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize }
/// place.
pub const line_col_sep: u8 = ':';
+/// ...and separates that spot from the END of a RANGE. A look at a ranged path
+/// SELECTS the span rather than just parking on its first cell, which is what
+/// lets a search result carry the text it matched and `n` land ON it.
+///
+/// Three spellings. The long one subsumes the other two, but the short ones
+/// are what a person actually types and what a grep-alike emits, so all three
+/// parse:
+/// main.zig:412-418 lines 412 through 418, whole
+/// main.zig:412:9-21 line 412, columns 9 through 21
+/// main.zig:412:9-418:1 line 412 column 9 through line 418 column 1
+/// Both ends are INCLUSIVE and 1-based, like the spot they extend — `412-418`
+/// reads as seven lines, not six. `main.zig:412` and `main.zig:412:9` keep
+/// meaning exactly what they always did.
+///
+/// Must be an isFileChar member, same as the separator above, or a click would
+/// expand to half a range. That is also why reading it is FUSSY (look.zig,
+/// parsePathLine): ordinary paths are full of dashes, so the suffix counts as
+/// a range only when a NUMBER follows the dash — `my-file:10` and `build-2`
+/// stay the paths they are.
+pub const range_sep: u8 = '-';
+
/// `@p7:10:5` — pane 7, line 10, column 5. The one look target that names a
/// live pane instead of a path, because terminals and output buffers have no
/// file for a location to point at. Both the writer (a `/` result row) and the
diff --git a/src/look.zig b/src/look.zig
index c4d04576..9df2b485 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -55,41 +55,125 @@ pub fn openLink(url: []const u8) void {
_ = libc.waitpid(pid, null, 0);
}
-/// peel a trailing :LINE[:COL] suffix (both 1-based, 0 = absent):
-/// main.zig:100 -> {main.zig, 100, 0}
-/// main.zig:100:7 -> {main.zig, 100, 7}
-/// main.zig:100: -> {main.zig, 100, 0} grep -n's trailing delimiter
-pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize, col: usize } {
+/// WHERE in a pane a look word points. A spot (`:LINE:COL`) — or a SPAN, when
+/// the word carries a range (config.range_sep), which a look SELECTS instead
+/// of merely parking on. Everything is 1-based and 0 means absent, so a bare
+/// path is the all-zero Spot and `end_line == 0` is the question "is this a
+/// range".
+pub const Spot = struct {
+ line: usize = 0,
+ col: usize = 0,
+ end_line: usize = 0,
+ /// 0 with a live `end_line` is the whole-lines form: through the END of
+ /// end_line, newline included, which is what helix's `x` selects.
+ end_col: usize = 0,
+};
+
+/// digits at `i` and where they end; `end == i` means there were none. Four
+/// numbers now come out of the same token, and spelling the scan four times
+/// is how one of them ends up subtly different from the others.
+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 };
+}
+
+/// peel a trailing :LINE[:COL] spot, or one of the three range spellings, off
+/// a look word (config.line_col_sep / config.range_sep own both characters):
+/// main.zig:100 -> line 100
+/// main.zig:100:7 -> line 100, col 7
+/// main.zig:100: -> line 100 grep -n's trailing delimiter
+/// main.zig:100-104 -> lines 100..104 whole
+/// main.zig:100:7-21 -> line 100, cols 7..21
+/// main.zig:100:7-104:3 -> line 100 col 7 .. line 104 col 3
+///
+/// A tail that does not parse leaves the token a plain PATH, which is the rule
+/// that keeps the dash safe: `a-b`, `build-2:3` and `x:1-y` are all paths (the
+/// last one goes back to hunting for a later ':' and finds none), because a
+/// range needs a number on both sides of its dash.
+pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot } {
var sep: usize = 0;
while (sep < tok.len) : (sep += 1) {
if (tok[sep] != config.line_col_sep) continue;
- var j = sep + 1;
- var line: usize = 0;
- while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
- if (j == sep + 1) continue; // no digits after ':'
- if (j < tok.len and tok[j] != config.line_col_sep) continue; // junk after the number
- var col: usize = 0;
- if (j < tok.len) {
- var k = j + 1;
- while (k < tok.len and std.ascii.isDigit(tok[k])) : (k += 1) col = col * 10 + (tok[k] - '0');
- // digits, and nothing but a delimiter after them, or no column
- if (k == j + 1 or (k < tok.len and tok[k] != config.line_col_sep)) col = 0;
+ const l = num(tok, sep + 1);
+ if (l.end == sep + 1) continue; // no digits after ':'
+ const path = tok[0..sep];
+ var i = l.end;
+ // `:LINE-ENDLINE`: whole lines, no column anywhere in the form
+ 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 } };
}
- return .{ .path = tok[0..sep], .line = line, .col = col };
+ 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 };
+ // `:COL`. A column that does not parse is dropped and the LINE still
+ // stands, which is how this has always read a half-mangled suffix.
+ const c = num(tok, i + 1);
+ if (c.end == i + 1) return .{ .path = path, .at = at };
+ 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 };
+ at.col = c.v;
+ i = c.end;
+ if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at };
+ // `-ENDCOL` on this same line, unless a `:ENDCOL` follows — then that
+ // first number was the end LINE all along. One lookahead, and it is
+ // what lets the two-number and four-number forms share a spelling.
+ const e = num(tok, i + 1);
+ if (e.end == i + 1) return .{ .path = path, .at = at };
+ at.end_line = at.line;
+ at.end_col = e.v;
+ 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;
+ }
+ }
+ return .{ .path = path, .at = at };
+ }
+ return .{ .path = tok, .at = .{} };
+}
+
+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 } },
+ // the dash cases that must stay ORDINARY PATHS
+ .{ .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 = .{} },
+ // a mangled tail still yields what parsed (unchanged behaviour)
+ .{ .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);
}
- return .{ .path = tok, .line = 0, .col = 0 };
}
pub const Target = union(enum) {
none,
dir: []const u8, // resolved absolute path, in caller's buf
- file: struct { path: []const u8, line: usize, col: usize },
+ file: struct { path: []const u8, at: Spot },
image: struct { path: []const u8 },
url: []const u8,
/// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one
/// target that names a live pane instead of a path, because terminals and
/// output buffers have no file for a location to point at.
- pane: struct { id: usize, line: usize, col: usize },
+ pane: struct { id: usize, at: Spot },
};
pub fn isImagePath(path: []const u8) bool {
@@ -114,7 +198,7 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
for (word[config.pane_addr.len..]) |c| {
if (!std.ascii.isDigit(c)) break;
id = id * 10 + (c - '0');
- } else return .{ .pane = .{ .id = id, .line = pl.line, .col = pl.col } };
+ } else return .{ .pane = .{ .id = id, .at = pl.at } };
}
// a URL is a URL everywhere: no filesystem can answer it, so it leaves the
@@ -134,12 +218,12 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
const resolved = std.mem.span(rp);
if (isDir(rp)) return .{ .dir = resolved };
if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } };
- return .{ .file = .{ .path = resolved, .line = pl.line, .col = pl.col } };
+ return .{ .file = .{ .path = resolved, .at = pl.at } };
} else {
// web: tracked Zig sources resolve inside the build-generated,
// read-only source filesystem.
if (resolveEmbedded(word, cwd, realbuf)) |source|
- return .{ .file = .{ .path = source.path, .line = pl.line, .col = pl.col } };
+ return .{ .file = .{ .path = source.path, .at = pl.at } };
return .none;
}
}
@@ -280,10 +364,12 @@ pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: *st
const grep_max_bytes = 256 * 1024;
const grep_max_files = 20_000;
-/// every line of `text` holding `pat`, as `path:LINE:COL text` rows — the
-/// shared half of grep(), and the shape every result row in pardes has: the
-/// leading word is a look target, so n/N walk the hits. Returns the rows
-/// written, at most `budget`.
+/// every line of `text` holding `pat`, as `path:LINE:COL-ENDCOL text` rows —
+/// the shared half of grep(), and the shape every result row in pardes has:
+/// the leading word is a look target, so n/N walk the hits. The row names the
+/// MATCH's span and not just its first cell, so stepping onto one selects the
+/// text that matched (config.range_sep). Returns the rows written, at most
+/// `budget`.
fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: []const u8, out: *std.ArrayList(u8), budget: usize) usize {
var n: usize = 0;
var line: usize = 0;
@@ -298,7 +384,9 @@ fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: [
const ln = std.mem.trimEnd(u8, raw, " \t\r");
var cut = @min(ln.len, 200);
while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1;
- const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ path, line, at + 1, ln[0..cut] }) catch break;
+ const row = std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{
+ path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut],
+ }) catch break;
out.appendSlice(arena, row) catch break;
n += 1;
}
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index abb91874..b8356724 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -7,10 +7,12 @@
//! language query is just another thing that answers later.
//!
//! Every backend renders into ONE format: `+Search` rows. A location is
-//! `path:LINE:COL text`, which is what look.zig already resolves and what n/N
-//! already steps, so a multi-result answer IS helix's picker and a single
-//! result IS a jump — with no picker UI written for it. Free text (hover,
-//! a rename's diff) rides the same buffer as plain lines.
+//! `path:LINE:COL text` — or `path:LINE:COL-ENDCOL text` where the protocol
+//! answered with a real range, which a look then SELECTS — and that is what
+//! look.zig already resolves and what n/N already steps, so a multi-result
+//! answer IS helix's picker and a single result IS a jump, with no picker UI
+//! written for it. Free text (hover, a rename's diff) rides the same buffer as
+//! plain lines.
//!
//! `query` is the ONLY thing an implementation supplies. Swapping backends is
//! swapping this one function, which is also how the three competing
@@ -101,6 +103,32 @@ pub fn row(
}) catch {};
}
+/// The same row for a protocol RANGE: `path:LINE:COL-ENDCOL`, which a look
+/// SELECTS rather than parking on its first cell — so `gd` lands on the whole
+/// name and a references list steps symbol by symbol with each one highlighted
+/// (config.range_sep spells the dash; `-` is written out here for the same
+/// reason `:` is).
+///
+/// `end_col` is the protocol's own EXCLUSIVE end character, which is already
+/// the 1-based inclusive column pardes wants, so the conversion is the absence
+/// of one. A span that is empty or crosses lines falls back to the point row:
+/// the only multi-line ranges here are whole declarations, and a goto onto one
+/// wants the cursor at its name, not its body painted.
+pub fn spanRow(
+ out: *std.Io.Writer,
+ path: []const u8,
+ line: usize,
+ col: usize,
+ end_line: usize,
+ end_col: usize,
+ text: []const u8,
+) void {
+ if (end_line != line or end_col <= col) return row(out, path, line, col, text);
+ out.print("{s}:{d}:{d}-{d} {s}\n", .{
+ path, line + 1, col + 1, end_col, std.mem.trim(u8, text, " \t\r\n"),
+ }) catch {};
+}
+
/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn
/// an AST token into a row, so it lives here rather than three times over.
pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } {
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index 8a36a151..6090af1a 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -484,7 +484,7 @@ fn rowForToken(arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std
if (th.token >= tree.tokens.len) return;
const r = offsets.tokenToRange(tree, th.token, enc);
const path = th.handle.uri.toFsPath(arena) catch return;
- lsp.row(out, path, r.start.line, r.start.character, lineAt(tree.source, r.start.line));
+ lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line));
}
// ---------------------------------------------------------------- goto
@@ -632,7 +632,7 @@ fn goto(
const t2 = &nd.handle.tree;
const rr = offsets.nodeToRange(t2, nd.node, enc);
const path = nd.handle.uri.toFsPath(arena) catch continue;
- lsp.row(out, path, rr.start.line, rr.start.character, lineAt(t2.source, rr.start.line));
+ lsp.spanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line));
},
}
}
@@ -703,7 +703,7 @@ fn emitSymbols(
try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d })
else
name;
- lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, text);
+ lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text);
if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out);
}
}
@@ -752,7 +752,7 @@ fn filterSymbols(
try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name });
if (containsIgnoreCase(s.name, needle)) {
n.* += 1;
- lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, name);
+ lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name);
}
if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out);
}
@@ -824,7 +824,7 @@ fn references(
try std.fmt.allocPrint(arena, "{s} -> {s} {s}", .{ want, nn, std.mem.trim(u8, lines.line(r.start.line), " \t") })
else
lines.line(r.start.line);
- lsp.row(out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, text);
+ lsp.spanRow(out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, r.end.line, r.end.character, text);
}
}
diff --git a/src/main.zig b/src/main.zig
index 436cf938..1a2510de 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -142,7 +142,7 @@ fn nativeMain(init: std.process.Init) !void {
switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) {
.file => |t| {
opts.file = try arena.dupe(u8, t.path);
- opts.file_line = t.line;
+ opts.file_line = t.at.line;
},
.image => |t| opts.file = try arena.dupe(u8, t.path),
else => return error.BadArgs,
diff --git a/src/pardes.zig b/src/pardes.zig
index fbf48f05..eb2c8402 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3665,8 +3665,12 @@ pub const Pardes = struct {
/// path when it has a real file, else `@pN`.
/// find — the pane's DIRECTORY, walked like fd. Rows are bare paths.
/// grep — the CONTENTS of every file under every pane's directory,
- /// walked like `grep -R`. Rows are `path:LINE:COL text`, the path
- /// relative to THIS pane's directory (absolute for a hit outside it).
+ /// walked like `grep -R`. Rows are `path:LINE:COL-ENDCOL text`, the
+ /// path relative to THIS pane's directory (absolute for a hit outside
+ /// it).
+ /// Both searches that match TEXT name the match's whole span, so looking a
+ /// row — which is all n/N do — selects what matched rather than parking on
+ /// its first cell. Find's rows are bare paths and have nothing to span.
/// No matches = an empty buffer.
pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search) void {
const pane = p.panes[id] orelse return;
@@ -3720,8 +3724,10 @@ pub const Pardes = struct {
std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return;
for (pl.lines, 0..) |ln, i| {
const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue;
- const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
- loc, i + 1, at + 1, std.mem.trimEnd(u8, ln, " \t"),
+ // the row names the MATCH's span, not just its first cell, so
+ // n/N land ON the hit with it selected (config.range_sep)
+ const row = 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"),
}) catch return;
out.appendSlice(arena, row) catch return;
}
@@ -6058,18 +6064,19 @@ pub const Pardes = struct {
// ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
- /// Focus pane `id` and, for a nonzero 1-based `line`, put its modal cursor
- /// there (`col` likewise, 0 = line start): files recenter the view on it,
- /// terminals ride their scrollback to it. Both look targets that name a
- /// live pane land here — a path a pane already holds, and `@pN:LINE:COL`.
- pub fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void {
+ /// Focus pane `id` and, for a nonzero 1-based `at.line`, put its modal
+ /// cursor there (`at.col` likewise, 0 = line start): files recenter the
+ /// view on it, terminals ride their scrollback to it. Both look targets
+ /// that name a live pane land here — a path a pane already holds, and
+ /// `@pN:LINE:COL`. A RANGED spot selects (selectSpan below).
+ pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot) void {
if (id >= MAX_PANES) return;
const pane = p.panes[id] orelse return;
p.active = id;
- if (line == 0) return;
+ if (at.line == 0) return;
if (pane.file) |*f| {
- if (line > file_pane.nlines(p.gpa, f)) return;
- const next = (line - 1) -| pane.rows / 2; // center, clamp at top
+ if (at.line > file_pane.nlines(p.gpa, f)) return;
+ const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
if (next != f.scroll) {
f.scroll = next;
f.syntax_dirty = true;
@@ -6078,16 +6085,44 @@ pub const Pardes = struct {
// land the modal cursor on the target line (and keep
// ensureCursorVisible agreeing with the recenter — a stale cursor
// would yank the view right back)
- pane.cur_row = @intCast(line - 1);
- pane.cur_col = if (col > 0) @intCast(col - 1) else 0;
+ pane.cur_row = @intCast(at.line - 1);
+ pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0;
pane.cur_pinned = true;
+ if (at.end_line != 0) return p.selectSpan(pane, at);
pane.ensureCursorVisible();
}
+ /// Select the span a RANGED look word names (config.range_sep): the two
+ /// ends are block-cursor CELLS, so this is the same cellRange/setPaneRange
+ /// pair every motion writes back through — the cursor lands on the span's
+ /// last cell with the anchor on its first, which is where helix leaves you
+ /// after a search too.
+ ///
+ /// EXPLICIT, so the acme chords act on it like a v/x selection: the whole
+ /// point of `n` selecting a hit is being able to chord the match straight
+ /// into the next command.
+ ///
+ /// Everything clamps, because a range is a claim about a file that may
+ /// have changed underneath it: hxOff pins a row past the end to the last
+ /// line and a column past the end to that line's terminator, so a stale
+ /// row selects what is still there instead of crashing or highlighting
+ /// garbage. A whole-lines range (no end column) runs to the terminator by
+ /// asking for a column no line can have, which is helix's own `x`.
+ fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void {
+ const pl = p.paneCursorLines(pane) catch return;
+ const text = p.flatSurface(pane, pl) 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 = cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol);
+ setPaneRange(pane, pl, text, r, true);
+ }
+
/// focus the pane already loaded on `path` (exact match), if any: file
/// panes recenter on a :NN line like the look dedup always has, image
/// panes just focus. Returns false when no pane holds that path.
- fn focusPaneByPath(p: *Pardes, path: []const u8, line: usize, col: usize) bool {
+ fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool {
for (p.panes, 0..) |slot, i| {
const tt = slot orelse continue;
if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) {
@@ -6096,7 +6131,7 @@ pub const Pardes = struct {
};
const ff = if (tt.file) |*f| f else continue;
if (!std.mem.eql(u8, ff.path, path)) continue;
- p.focusPaneLine(i, line, col);
+ p.focusPaneLine(i, at);
return true;
}
return false;
@@ -6126,7 +6161,7 @@ pub const Pardes = struct {
// canonical (realpath'd or dump-given), so match the word as-is
// here and joined onto each directory below.
const pl = look.parsePathLine(trimmed);
- if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.line, pl.col)) return;
+ if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return;
// The word is resolved against the pane DIRECTORIES in access
// order: the pane the click came from FIRST — its answer is the
// one taken, so nothing that resolves today moves — then every
@@ -6155,7 +6190,7 @@ pub const Pardes = struct {
if (pl.path.len > 0 and pl.path[0] != '/') {
var joinbuf: [2048]u8 = undefined;
if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j|
- if (p.focusPaneByPath(j, pl.line, pl.col)) return;
+ if (p.focusPaneByPath(j, pl.at)) return;
}
found = look.resolve(txt, dir, &realbuf);
if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break;
@@ -6175,7 +6210,7 @@ pub const Pardes = struct {
// `@p7:10:5`: pane 7, line 10, column 5 — how a search result
// points at a terminal or an output buffer, neither of which
// has a path.
- .pane => |t| p.focusPaneLine(t.id, t.line, t.col),
+ .pane => |t| p.focusPaneLine(t.id, t.at),
.url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
.dir => |dir| {
// focus an existing terminal on this dir, else fork one below
@@ -6197,19 +6232,29 @@ pub const Pardes = struct {
},
.file => |target| {
// focus an existing pane on this path (rescrolled), else open
- if (p.focusPaneByPath(target.path, target.line, target.col)) return;
+ if (p.focusPaneByPath(target.path, target.at)) return;
const free = p.freeSlot() orelse return;
- const nt = file_pane.open(p, free, target.path, target.line) catch return;
- if (target.col > 0) nt.cur_col = @intCast(target.col - 1);
+ const nt = file_pane.open(p, free, target.path, target.at.line) catch return;
+ if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1);
p.placeDoc(id, free, nt);
// center the target line: the pane's real body height only
// exists after placement, so lay out now and pull the
// scroll up by half a body (line 0 opens stay at the top)
p.computeGeom();
nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
+ // ...and only THEN select a range, restoring that scroll:
+ // setPaneRange keeps its cursor visible, and a pane this fresh
+ // has no true geometry yet for it to judge against (pane.rows
+ // is only refreshed in sync), so the centering just computed is
+ // the answer and ensureCursorVisible's is not.
+ if (target.at.end_line != 0) {
+ const centered = nt.file.?.scroll;
+ p.selectSpan(nt, target.at);
+ nt.file.?.scroll = centered;
+ }
},
.image => |target| {
- if (p.focusPaneByPath(target.path, 0, 0)) return;
+ if (p.focusPaneByPath(target.path, .{})) return;
const free = p.freeSlot() orelse return;
// petscii by default when the host has no kitty graphics
const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return;
@@ -6716,7 +6761,7 @@ pub const Pardes = struct {
if (p.njumps == 0 or next < 0 or next >= p.njumps) return;
p.jcur = @intCast(next);
const j = p.jumps[p.jcur];
- p.focusPaneLine(j.pane, j.line, j.col);
+ p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col });
}
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
diff --git a/src/tutor.txt b/src/tutor.txt
index 2d0e951a..ee6f9f79 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -166,11 +166,16 @@
pane's directory searches for it — acme's button 3 — except in tty mode,
where the click belongs to the program on the other end. On a shell with no
search armed, n/N instead step the lookable tokens in its output.
- A hit in a file reads `path:LINE:COL`, the ordinary look target. A hit in
- a shell or an output buffer has no file to name, so it reads `@pN:LINE:COL`
- — pane N, line LINE, column COL. Looking either one goes there; the column
- is optional (`main.zig:100`, `@p3:12`) and you can type one yourself
- anywhere text lives.
+ A hit in a file reads `path:LINE:COL-ENDCOL`, the ordinary look target
+ carrying the SPAN that matched. A hit in a shell or an output buffer has no
+ file to name, so it reads `@pN:LINE:COL-ENDCOL` — pane N, then the place in
+ it. Looking either one goes there and SELECTS the span, which is why n/N
+ land ON a hit rather than beside it.
+ The range is part of the PATH syntax and not part of search: type one
+ anywhere text lives and a look on it selects. `main.zig:412-418` is whole
+ lines, `main.zig:412:9-21` is columns on one line, `main.zig:412:9-418:1`
+ is the general form, and the shorter spellings still mean what they always
+ did — `main.zig:100`, `main.zig:100:7`, `@p3:12`, all of them optional.
FIND: the "Find" builtin (SPC f f) arms the same tag input, but Enter
walks the pane's DIRECTORY instead of its text — `fd`, in-core — and writes
one matching PATH per row into the same "+Search" buffer. Rows are look