diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-24 22:18:24 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 09:42:07 -0300 |
| commit | 08eadb3b46dc70a297ff89308dd1cd26237785d6 (patch) | |
| tree | 66152ea18a3639dd216b6e658d9cd5503201afdc /src/pardes.zig | |
| parent | c3d0b84b7961ae26d2d654e7120821cc2d83d20d (diff) | |
| download | pardes-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.zig | 154 |
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; |
