diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 11:42:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | 911215846f1d5a9b2e37a00e74fb9266d4a0884a (patch) | |
| tree | 31413ca3336cbbd1933d9dc3e24f09a3b8ef46b4 /src | |
| parent | 0b7a480bef38b209741c520e2478d178767a9e51 (diff) | |
| download | pardes-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.zig | 2 | ||||
| -rw-r--r-- | src/config.zig | 21 | ||||
| -rw-r--r-- | src/look.zig | 144 | ||||
| -rw-r--r-- | src/lsp/lsp.zig | 36 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 10 | ||||
| -rw-r--r-- | src/main.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 93 | ||||
| -rw-r--r-- | src/tutor.txt | 15 |
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 |
