summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-24 22:18:24 -0300
committerGabriel Schneider <[email protected]>2026-08-25 09:42:07 -0300
commit08eadb3b46dc70a297ff89308dd1cd26237785d6 (patch)
tree66152ea18a3639dd216b6e658d9cd5503201afdc /src/pardes.zig
parentc3d0b84b7961ae26d2d654e7120821cc2d83d20d (diff)
downloadpardes-08eadb3b46dc70a297ff89308dd1cd26237785d6.tar.gz
pardes-08eadb3b46dc70a297ff89308dd1cd26237785d6.zip
syntax + pdf_pane + file_pane: prose grammars paint, pdf Esc cancels, gj/gk walk wrapped rows
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig154
1 files changed, 148 insertions, 6 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 405cc3f2..0a390161 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -254,6 +254,72 @@ test "selection pipe prompt submits exact request and Escape cancels" {
try std.testing.expect(nextPipeEffect(p) == null);
}
+test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const long = "a" ** 400;
+ const pane = try p.hxOpenFileContent(long ++ "\nsecond\n");
+ p.settings.wrap = true;
+ const width = file_pane.wrapWidth(pane, true);
+ try std.testing.expect(width > 4 and long.len > width * 3);
+
+ // gj holds the column INSIDE the row and lands on the next break; the line
+ // is one line the whole way down, which is the whole point.
+ pane.cur_row = 0;
+ pane.cur_col = 3;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, @intCast(width * 2 + 3)), pane.cur_col);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col);
+ // A count is counted in rows, not lines.
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, @intCast(width * 3 + 3)), pane.cur_col);
+
+ // Plain j/k are untouched: one FILE line each, whatever the body drew.
+ pane.cur_col = 3;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+
+ // The last row of a line steps into the next line's first row, and the
+ // first row steps back onto the previous line's LAST row.
+ pane.cur_row = 0;
+ pane.cur_col = @intCast(long.len - 1);
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ pane.cur_col = 0;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expect(pane.cur_col >= @as(i32, @intCast(long.len - long.len % width)));
+
+ // Wrap OFF: a line is one visual row, so gj/gk ARE j/k again.
+ p.settings.wrap = false;
+ pane.cur_row = 0;
+ pane.cur_col = 3;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+}
+
test "selection pipe replaces all ranges atomically and undo restores them" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
@@ -8258,6 +8324,76 @@ pub const Pardes = struct {
pane.sticky_col = @intCast(goal);
}
+ /// `gj`/`gk`: one VISUAL line, following the automatic breaks a wrapped
+ /// body draws rather than the newlines in the file. The goal column is the
+ /// one INSIDE the visual row, so a run of them walks straight down a
+ /// paragraph; on the last visual row of a line the step crosses into the
+ /// next line's first row, exactly as the eye does.
+ ///
+ /// With wrapping off — Wrap unset, a terminal pane, an output pane too
+ /// narrow to record its map — a line is one visual row and this IS
+ /// verticalMove, which is why nothing upstream branches on the setting.
+ fn visualMove(
+ pane: *Pane,
+ pl: PaneLines,
+ text: []const u8,
+ range: modal.HxRange,
+ down: bool,
+ cnt: usize,
+ width: usize,
+ ) void {
+ const cur = modal.hxCursor(text, range);
+ const pos = panePos(pane, text, cur);
+ const last_row = paneLineCount(pane, text) - 1;
+ var row = pos.row;
+ var line = modal.lineSlice(text, row);
+ var vrow = file_pane.visualRow(line, pos.col, width);
+ const goal: usize = if (pane.sticky_col >= 0)
+ @intCast(pane.sticky_col)
+ else
+ file_pane.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start);
+ var steps = @max(1, cnt);
+ while (steps > 0) : (steps -= 1) {
+ if (down) {
+ if (vrow.end < line.len) {
+ vrow = file_pane.visualRow(line, vrow.end, width);
+ continue;
+ }
+ if (row == last_row) break;
+ row += 1;
+ line = modal.lineSlice(text, row);
+ vrow = file_pane.visualRow(line, 0, width);
+ } else {
+ if (vrow.start > 0) {
+ vrow = file_pane.visualRow(line, vrow.start - 1, width);
+ continue;
+ }
+ if (row == 0) break;
+ row -= 1;
+ line = modal.lineSlice(text, row);
+ vrow = file_pane.visualRow(line, line.len, width);
+ }
+ }
+ // The newline slot is a real cursor position, but the first byte of the
+ // NEXT visual row is not: landing there would read as two rows moved.
+ var target_col = vrow.start + file_pane.rawAtDisplay(line[vrow.start..vrow.end], goal);
+ if (vrow.end < line.len and target_col >= vrow.end)
+ target_col = modal.graphemeStart(line, vrow.end - 1);
+ const t = paneOff(pane, text, .{ .row = row, .col = target_col });
+ // extend mode never walks onto the empty trailing line (helix)
+ if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return;
+ setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false);
+ pane.sticky_col = @intCast(goal);
+ }
+
+ /// How wide a wrapped row of this pane is, or 0 when it does not wrap.
+ /// Only a file-backed body wraps: a terminal's rows are the emulator's
+ /// own, already broken where it decided to break them.
+ fn paneWrapWidth(p: *const Pardes, pane: *const Pane) usize {
+ if (pane.file == null) return 0;
+ return file_pane.wrapWidth(pane, p.settings.wrap);
+ }
+
/// Ctrl-d/u: scroll half a page AND move the cursor by the same rows
fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void {
const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2));
@@ -8414,8 +8550,8 @@ pub const Pardes = struct {
if (nw == slice.len) return;
return pointMove(pane, pl, text, range, ls + nw);
},
- .line_down => return verticalMove(pane, pl, text, range, true, go.count),
- .line_up => return verticalMove(pane, pl, text, range, false, go.count),
+ .line_down => return visualMove(pane, pl, text, range, true, go.count, p.paneWrapWidth(pane)),
+ .line_up => return visualMove(pane, pl, text, range, false, go.count, p.paneWrapWidth(pane)),
.column => {
const line = modal.hxLineOf(text, cur);
const ls = modal.lineStartOffset(text, line);
@@ -12360,6 +12496,16 @@ pub const Pardes = struct {
if (comptime !pdf_enabled) return;
if (pane.pdf == null) return;
var state = paneNormalState(pane);
+ // The way OUT of a PDF, because plain Escape below is the document's
+ // own cancel (selection, search overlay) and never moves focus. Before
+ // the parser: `config.escape` matches a shifted Escape too, since shift
+ // is consulted only where a binding asks for it.
+ if (hit(key, config.leave_pane)) {
+ state.clear();
+ putPaneNormalState(pane, state);
+ pane.select = false;
+ return p.runBuiltin(.Last, p.active, "", null);
+ }
// A PDF has no body character to find, so its bare `f` is the direct
// document-outline door. Prefix continuations still go through the
// shared parser, and text/terminal panes retain `f<char>` unchanged.
@@ -12385,10 +12531,6 @@ pub const Pardes = struct {
if (result.page_changed) pdf_pane.resetPageChrome(pane);
switch (result.host) {
.none => {},
- .last => {
- pane.select = false;
- p.runBuiltin(.Last, p.active, "", null);
- },
.leader => {
p.leader_on = true;
p.leader_n = 0;