summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 16:46:46 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitec4f300060ec0c93cc920dd5562eae1e3153d8b7 (patch)
tree1a5da619f2e607c0805ce7f04eacb18ffe6d72f6 /src
parent71c5b400ced873cddd77497b7a9fd06184954813 (diff)
downloadpardes-ec4f300060ec0c93cc920dd5562eae1e3153d8b7.tar.gz
pardes-ec4f300060ec0c93cc920dd5562eae1e3153d8b7.zip
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 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/body_layer.zig23
-rw-r--r--src/config.zig3
-rw-r--r--src/modal.zig51
-rw-r--r--src/normal.zig39
-rw-r--r--src/panes.zig4
-rw-r--r--src/pardes.zig2
6 files changed, 122 insertions, 0 deletions
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.