summaryrefslogtreecommitdiff
path: root/src/pardes.zig
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/pardes.zig
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/pardes.zig')
-rw-r--r--src/pardes.zig93
1 files changed, 69 insertions, 24 deletions
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