From ec4f300060ec0c93cc920dd5562eae1e3153d8b7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:46:46 -0300 Subject: gw labels the words in view and jumps to the one whose label is typed helix goto_word: the words of two or more word characters between the view's first and last lines get two-letter labels, nearest the cursor first, one forward and one back in turn (modal.jumpWords). The next two keys choose one and select it, stretching the selection to it in select mode; any other key takes the labels away. The words live on Pane.jump, and renderBody paints their labels over the body's cells in the selection colours, the one change on the render side. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/body_layer.zig | 23 +++++++++++++++++++++ src/config.zig | 3 +++ src/modal.zig | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/normal.zig | 39 +++++++++++++++++++++++++++++++++++ src/panes.zig | 4 ++++ src/pardes.zig | 2 ++ test/hxcases/cases.jsonl | 7 +++++++ test/hxcases/goldens.jsonl | 7 +++++++ 9 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 8ee81930..fedf578d 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -187,6 +187,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `g|`, `g|` | goto column n (1 = line start), clamped to the line | | helix-verified (phase 5) | | `gs` | goto first non-whitespace | alias of the `^` handler | helix-verified (phase 5) | | `g.` | go to where the last edit ended (a deletion's start, an insertion's end), every range there, extending in select mode | helix `goto_last_modification`. The position is `Text.last_edit`, set by `edit.setEditText` from where the old and new texts part; helix's is the last history revision's, so with nothing edited yet pardes stays put where the harness's helix goes to the end of its setup text | helix-verified | +| `gw` | label the words in view (two word characters or more) with two letters each, nearest the cursor first, one forward and one back in turn; typing a label selects its word (stretching the selection to it in select mode), any other key takes the labels away | helix `goto_word`, alphabet a-z (`config.jump_label_alphabet`). The labels are painted over the body's cells in `body_layer.renderBody`, in the selection colours; the words live on `Pane.jump` | helix-verified | | `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) | | `gj` / `gk` | VISUAL line down / up (+ count) | follows the wrapped body's own breaks (`file_pane.visualRow`, the same walk `fillBody` renders), keeping the goal column INSIDE the row; the last row of a line steps into the next line's first. Wrap off = one row per line, and this IS `j`/`k`. helix assigns the pair the other way round (its `j` is the visual one) | pardes-specific | | `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) | @@ -355,7 +356,6 @@ text. | Popup `Ctrl-u`/`Ctrl-d`, Completion menu, Signature help tables | LSP popups | LSP | | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | -| `gw` | word-label jump | label-jump overlay machinery, not core editing | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | diff --git a/src/body_layer.zig b/src/body_layer.zig index c8e229c4..a354512f 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -477,6 +477,29 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id } } + // `gw`'s labels, two letters over the start of each word + if (pane.jump.items.len > 0) labels: { + const flat = edit.flatSurface(p, &pane.body) catch break :labels; + const letters = config.jump_label_alphabet; + const style: CellStyle = .{ .fg = .{ .rgb = th.sel_fg }, .bg = .{ .rgb = th.sel_bg }, .bold = true }; + for (pane.jump.items, 0..) |word, i| { + if (pane.jump_first) |first| if (i / letters.len != first) continue; + var at = @min(word.anchor, word.head); + for ([_]u8{ letters[i / letters.len], letters[i % letters.len] }) |letter| { + const pos = modal.positionAt(flat, at); + const row: i32 = @intCast(pos.row); + const wp = pane.wrapRow(row, @intCast(pos.col)); + const cx = if (pane.file != null) + @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, wp.at, @intCast(pos.col)) + else + panes.File.lineDisplayOffset(modal.lineSlice(flat, pos.row), @intCast(@max(0, wp.at)), pos.col); + if (wp.row >= 0 and wp.row < body_h and cx >= 0 and cx < tw) + s.set(tx + @as(u16, @intCast(cx)), body_y + @as(u16, @intCast(wp.row)), &.{letter}, style); + at = modal.nextGrapheme(flat, at); + } + } + } + // cursor: tracks the shell cursor until pinned by a click or a key // (the tag's or a prompt's cursor wins while that is being typed into) if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { diff --git a/src/config.zig b/src/config.zig index daa40028..3739d3f4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -490,6 +490,9 @@ pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }}; +pub const goto_word: []const Chord = &.{.{ .cp = 'w' }}; +/// the jump labels' letters, helix's jump_label_alphabet +pub const jump_label_alphabet = "abcdefghijklmnopqrstuvwxyz"; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; diff --git a/src/modal.zig b/src/modal.zig index 16196b4f..6371475c 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -30,6 +30,7 @@ pub const Normal = struct { goto_view_center, goto_view_bottom, goto_last_modification, + goto_word, goto_definition, goto_declaration, goto_type_definition, @@ -346,6 +347,7 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + goto_word, macro_record, macro_replay: u32, node: NodeWalk, @@ -364,6 +366,7 @@ pub const Normal = struct { .macro_record, .macro_replay, .node, + .goto_word, .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { @@ -447,6 +450,7 @@ pub const Normal = struct { if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_word)) return resultAction(.goto_word); if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); @@ -1382,6 +1386,53 @@ fn wmIsPrev(t: WordTarget) bool { } /// w/b/e/W/B/E: helix word_move — each step selects the traversed span. +/// helix jump_to_word's candidates: the words of two or more word +/// characters between `start` and `end`, nearest the cursor first, taking one +/// forward and one back in turn, each over its word pointing the way it was +/// found, as many as `out` holds. +pub fn jumpWords(text: []const u8, cursor: usize, start: usize, end: usize, out: []Selection) usize { + var fwd: Selection = .{ .anchor = cursor, .head = cursor }; + var rev = fwd; + if (cursor < text.len and !isWordWhitespace(wordClassAt(text, cursor))) { + const word_end = moveWord(text, fwd, 1, .next_word_end); + if (word_end.anchor == cursor) fwd = word_end; // a word of one grapheme + const word_start = moveWord(text, rev, 1, .prev_word_start); + if (word_start.anchor == nextGrapheme(text, cursor)) rev = word_start; + } + var n: usize = 0; + while (n < out.len) { + var changed = false; + while (fwd.head < end) { + fwd = moveWord(text, fwd, 1, .next_word_end); + // the two graphemes before the head are word characters + const one = prevGrapheme(text, fwd.head); + if (fwd.head == 0 or one == 0 or wordClassAt(text, one) != .word or wordClassAt(text, prevGrapheme(text, one)) != .word) continue; + changed = true; + var from = fwd.anchor; + while (from < fwd.head and wordClassAt(text, from) != .word) from = nextGrapheme(text, from); + fwd.anchor = from; + out[n] = fwd; + n += 1; + break; + } + if (n == out.len) break; + while (rev.head > start) { + rev = moveWord(text, rev, 1, .prev_word_start); + // the two graphemes from the head are word characters + if (wordClassAt(text, rev.head) != .word or wordClassAt(text, nextGrapheme(text, rev.head)) != .word) continue; + changed = true; + var to = rev.anchor; + while (to > rev.head and wordClassAt(text, prevGrapheme(text, to)) != .word) to = prevGrapheme(text, to); + rev.anchor = to; + out[n] = rev; + n += 1; + break; + } + if (!changed) break; + } + return n; +} + pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection { const is_prev = wmIsPrev(target); if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0; diff --git a/src/normal.zig b/src/normal.zig index f7ba3810..4459ab53 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -245,6 +245,44 @@ pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl); } +/// `gw` (helix goto_word): label the words in view, nearest the cursor first, +/// with two letters each; the next two keys choose one (`jumpKey`). +fn startJump(p: *Pardes, pane: *Pane, t: *Text) void { + if (t.what != .body) return; + const text = edit.flatSurface(p, t) catch return; + const cursor = modal.selectionCursor(text, t.primaryRange(text, 0)); + const top: usize = @intCast(@max(0, pane.scroll())); + const start = @min(modal.lineStartOffset(text, top), text.len); + const end = @min(modal.lineStartOffset(text, top + pane.rows), text.len); + const letters = config.jump_label_alphabet.len; + pane.jump.resize(p.gpa, letters * letters) catch return; + pane.jump.shrinkRetainingCapacity(modal.jumpWords(text, cursor, start, end, pane.jump.items)); + pane.jump_first = null; +} + +/// A key while `gw`'s labels are up: a label's first letter, then its +/// second, which selects that word; anything else takes the labels away. +pub fn jumpKey(p: *Pardes, pane: *Pane, t: *Text, key: Key) void { + const letters = config.jump_label_alphabet; + const at = if (key.ctrl or key.alt or key.cp > 0x7f) null else std.mem.indexOfScalar(u8, letters, @intCast(key.cp)); + const i = at orelse return pane.jump.clearRetainingCapacity(); + const first = pane.jump_first orelse { + if (i * letters.len > pane.jump.items.len) return pane.jump.clearRetainingCapacity(); + pane.jump_first = @intCast(i); + return; + }; + const chosen = first * letters.len + i; + defer pane.jump.clearRetainingCapacity(); + if (chosen >= pane.jump.items.len) return; + const r = pane.jump.items[chosen]; + const text = edit.flatSurface(p, t) catch return; + if (!t.select) return t.setRange(text, 0, .{ .anchor = @min(r.anchor, r.head), .head = @max(r.anchor, r.head) }, true); + // select mode stretches the primary to the word (helix Movement::Extend) + const pr = t.primaryRange(text, 0); + const anchor = if (r.anchor < r.head) @min(r.anchor, @min(pr.anchor, pr.head)) else @max(r.anchor, @max(pr.anchor, pr.head)); + t.setRange(text, 0, .{ .anchor = anchor, .head = r.head }, true); +} + const KeyState = struct { mode: Text.Mode, select: bool, @@ -706,6 +744,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void if (direction == .forward) @as(i32, 1) else -1, ), .node => |walk| return nodeSelect(p, pane, t, walk), + .goto_word => return startJump(p, pane, t), .macro_record => { const m = &p.macro; if (m.recording == 0) { diff --git a/src/panes.zig b/src/panes.zig index 67954abd..7114b68e 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -154,6 +154,10 @@ pub const Pane = struct { search_pane: ?usize = null, search_row: ?usize = null, look_at: ?LookSpot = null, + /// `gw`'s words, labelled in order while it waits for a label to be + /// typed (gpa-owned); `jump_first` is the label's first letter once typed. + jump: std.ArrayList(modal.Selection) = .empty, + jump_first: ?u8 = null, sel_snap: [Text.max_selections]modal.Selection = undefined, nsel_snap: u16 = 0, sel_snap_pri: u16 = 0, diff --git a/src/pardes.zig b/src/pardes.zig index d43b38b1..6b397017 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4395,6 +4395,7 @@ pub const Pardes = struct { pane.body.deinit(p.gpa); pane.tag.deinit(p.gpa); pane.input.deinit(p.gpa); + pane.jump.deinit(p.gpa); panes.Terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); pane.fs.deinit(p.gpa); @@ -5284,6 +5285,7 @@ pub const Pardes = struct { if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode); return; } + if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key); if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 15988ef6..f36afe11 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -459,6 +459,13 @@ {"name": "sticky-view-ignores", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjxj"} {"name": "view-middle", "pane": "file", "text": "abcdef\n", "keys": "3lzm"} {"name": "view-z-not-sticky", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "zjj"} +{"name": "goto-word-first", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "gwaa"} +{"name": "goto-word-second", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "gwab"} +{"name": "goto-word-back", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "jwgwab"} +{"name": "goto-word-cancel", "pane": "file", "text": "alpha beta gamma\n", "keys": "gwzl"} +{"name": "goto-word-bad-second", "pane": "file", "text": "alpha beta\n", "keys": "gwa9l"} +{"name": "goto-word-extend", "pane": "file", "text": "alpha beta gamma\n", "keys": "wvgwab"} +{"name": "goto-word-mid", "pane": "file", "text": "one two three four five\n", "keys": "2wgwaa"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index d434c834..2dab15c0 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -459,6 +459,13 @@ {"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} {"name":"view-middle","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} {"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} +{"name":"goto-word-first","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":9},"anchor":{"row":0,"col":6}} +{"name":"goto-word-second","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":15},"anchor":{"row":0,"col":11}} +{"name":"goto-word-back","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":15},"anchor":{"row":0,"col":11}} +{"name":"goto-word-cancel","text":"alpha beta gamma\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"goto-word-bad-second","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"goto-word-extend","text":"alpha beta gamma\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}} +{"name":"goto-word-mid","text":"one two three four five\n","mode":"normal","cursor":{"row":0,"col":12},"anchor":{"row":0,"col":8}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3