diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-05 15:57:48 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 1891a51ed2b64eec3964d3af3807be90d08a14c2 (patch) | |
| tree | 1f8e3afb3d2e765ade98ca8f41b3a416db396d24 | |
| parent | 68d208e1a0ff37eaef4a2a97b04797a1a6df277c (diff) | |
| download | pardes-1891a51ed2b64eec3964d3af3807be90d08a14c2.tar.gz pardes-1891a51ed2b64eec3964d3af3807be90d08a14c2.zip | |
soft-wrap long lines behind a toggle
| -rw-r--r-- | next-steps.txt | 2 | ||||
| -rw-r--r-- | src/builtins.zig | 10 | ||||
| -rw-r--r-- | src/config.zig | 6 | ||||
| -rw-r--r-- | src/file_pane.zig | 111 | ||||
| -rw-r--r-- | src/pardes.zig | 170 | ||||
| -rw-r--r-- | test/snapshots/builtins.golden | 44 | ||||
| -rw-r--r-- | test/snapshots/leader.golden | 6 | ||||
| -rw-r--r-- | test/snapshots/wrap.golden | 90 | ||||
| -rw-r--r-- | test/snapshots/wrap.snap | 54 |
9 files changed, 418 insertions, 75 deletions
diff --git a/next-steps.txt b/next-steps.txt index 3bea60cd..faca8d23 100644 --- a/next-steps.txt +++ b/next-steps.txt @@ -1,4 +1,4 @@ + The selection colors should be based on the theme. + The default layout for when just opening a file should be just the file without the term. -- Implement soft line breaks with a builtin to toggle it; the line breaks at min(100 chars, window width) ++ Implement soft line breaks with a builtin to toggle it; the line breaks at min(100 chars, window width) - A new UI semantic element will be implemented which is the transient message section: it will be the latest row of a pane. When executing any builtin that requires and string argument but none was provided right now it should ask for input at the tagline. now it will be at this transient msg that will be left-aligned on the last row. Also when saving a file or a file is reloaded it should display a message there saying the time and what happened. the message should clear when any key on the keyboard is pressed or any mouse button is moved
\ No newline at end of file diff --git a/src/builtins.zig b/src/builtins.zig index 9bd955bd..aaeab5fa 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -181,6 +181,16 @@ pub const Colors = struct { } }; +/// Soft wrap: a file line too long for the pane renders across several body +/// rows instead of running off the right edge. Global like Colors, and off by +/// default for the same reason — it changes what every file pane looks like, +/// so it is a switch you flip once rather than something a pane carries. +pub const Wrap = struct { + pub fn run(c: Ctx) void { + c.p.wrap_on = !c.p.wrap_on; + } +}; + /// One step along the ring. With 228 themes in it this is no longer a way to /// REACH a theme — ThemeSel is — but it is still the way to browse one, and the /// browse got better rather than worse: the generated half is sorted by name, so diff --git a/src/config.zig b/src/config.zig index f8a3767b..b7ca9b11 100644 --- a/src/config.zig +++ b/src/config.zig @@ -120,6 +120,7 @@ pub const leader_path = paths: { .Delcol = "cd", .Debug = "td", .Colors = "tc", + .Wrap = "tw", .NextColor = "tn", .Crt = "tr", // the theme picker joins the toggles it belongs with; `Theme` itself takes @@ -431,6 +432,11 @@ test "wheel drift guard" { /// the file pane's line-number gutter, in columns pub const PREFIX_W: u16 = 5; +/// soft wrap (the Wrap builtin): the widest a wrapped row may get. The rule is +/// min(this, the pane's own content width), so a narrow pane breaks at its own +/// right edge and a wide one stops here — a line dragged across a 300-column +/// monitor is not readable text, it is a scanline. +pub const WRAP_COLS: usize = 100; /// vim 'scrolloff': keyboard cursor moves keep this many context rows visible /// above/below the cursor (clamped at file boundaries and short panes), and /// the same count of COLUMNS horizontally diff --git a/src/file_pane.zig b/src/file_pane.zig index 36818d8d..b76a7882 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -179,10 +179,26 @@ pub fn refreshHighlights(p: *Pardes) void { } } -/// the body a file pane renders: `pane.rows` content lines from the scroll +/// the body a file pane renders: `pane.rows` SCREEN rows from the scroll /// offset, each behind its right-aligned line number, then cut by hscroll. -pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 { +/// +/// With `wrap` on a line too long for the pane takes several rows instead of +/// running off the right edge, and this is where that happens — it is a render +/// property and nothing else in the editor learns about it. What every other +/// site needs is the map back: which line each row showed and at which byte +/// column it began, recorded into pane.wrap_line/wrap_col as the rows are +/// built. `wrap_n` stays 0 for an unwrapped body, and that is the value the +/// readers treat as "rows are lines", so the off path never consults an array. +pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]const u8 { var out: std.ArrayList(u8) = .empty; + // min(WRAP_COLS, the pane's own content width), and 0 means don't wrap. + // A pane taller than the map refuses too, rather than recording part of + // itself — see Pane.wrap_line. + const width: usize = if (!wrap or pane.rows > pane.wrap_line.len) + 0 + else + @max(1, @min(config.WRAP_COLS, @as(usize, pane.cols -| config.PREFIX_W))); + pane.wrap_n = 0; // start ON the first visible line instead of walking the file to it: this // walk was O(f.scroll) and recolorSyntax below ran the identical one again var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n'); @@ -190,23 +206,52 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 { // old walk left the iterator dry, so drop the one empty line a slice split // still yields, or the body grows a phantom numbered row if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next(); + // the line the NEXT row comes from and the byte column of it that row + // starts at — the two the map records, walked forward by the loop + var abs: i32 = @intCast(f.scroll); + var at: usize = 0; + var cur = flines.next(); var i: usize = 0; while (i < pane.rows) : (i += 1) { if (i > 0) try out.append(arena, '\n'); - const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i)); + if (width > 0) { + pane.wrap_line[i] = abs; + pane.wrap_col[i] = @intCast(at); + pane.wrap_n = @intCast(i + 1); + } const row_start = out.items.len; - if (flines.next()) |text| { + if (cur) |text| { var lbuf: [16]u8 = undefined; // unsigned: {d} prints a leading '+' for signed ints const lineno: usize = @intCast(abs + 1); - const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; - var base: std.ArrayList(u8) = .empty; - try base.appendSlice(arena, prefix); - try base.appendSlice(arena, text); - try out.appendSlice(arena, base.items); - } - // horizontal scroll: cut hscroll columns after the gutter - if (pane.hscroll > 0) { + // the number belongs to the LINE, so only its first row carries + // one — repeated down a wrapped line it would read as several + // lines, which is exactly what this is not + const prefix = if (at > 0) + " " + else + std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; + try out.appendSlice(arena, prefix); + // A byte cut, like the hscroll one below, and it can land inside a + // multi-byte glyph for the same reason: columns here are BYTES. + // Surface.print decodes by hand and emits U+FFFD per undecodable + // byte, so a split glyph renders as a replacement char rather than + // panicking — see test/snapshots/badutf.snap. It cannot overflow + // the pane either: a UTF-8 sequence is never fewer bytes than the + // cells it draws in. + const take = if (width == 0) text.len - at else @min(width, text.len - at); + try out.appendSlice(arena, text[at .. at + take]); + if (width > 0 and at + take < text.len) { + at += take; + } else { + abs += 1; + at = 0; + cur = flines.next(); + } + } else abs += 1; + // horizontal scroll: cut hscroll columns after the gutter. Wrapped + // there is nothing to scroll to, so the cut is not made at all. + if (pane.hscroll > 0 and width == 0) { const cut_at = row_start + config.PREFIX_W; if (out.items.len > cut_at) { const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at); @@ -227,10 +272,16 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod const goff = pane.scroll(); const gcur = pane.vt.screens.active.cursor; const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; - const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; + // the cursor's LINE, not its row: wrapped, one line owns a run of rows and + // the number sits on the first of them, so the whole run lights up — the + // gutter is naming the line you are on, and that is still one line + const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { - const on_cursor = @as(i32, vr) == cur_vr; + const row_line: i32 = if (pane.wrap_n == 0) + goff + @as(i32, vr) + else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32); + const on_cursor = row_line == cur_line; var c: u16 = 0; while (c < config.PREFIX_W and c < tw) : (c += 1) { const cell = s.at(tx + c, r.y + pardes.BOX_H + vr); @@ -264,13 +315,37 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16, defer tz_recolor.end(); // indexed start, same as bodyText — an empty tail simply paints nothing var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); - const hs: usize = @intCast(@max(0, pane.hscroll)); + const total = nlines(p.gpa, f); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { - const line = flines.next() orelse break; - const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); + // A colour has to land on the byte it belongs to, so this walk reads + // the same map the body was built from: wrapped, the screen row names + // its own line and the byte column it began at, and it ends where the + // NEXT row of that line begins. Unwrapped the rows ARE the lines in + // order and the split iterator is the cheaper walk. + var base: usize = undefined; + var line: []const u8 = undefined; + var hs: usize = @intCast(@max(0, pane.hscroll)); + var limit: usize = undefined; + if (pane.wrap_n == 0) { + line = flines.next() orelse break; + base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); + limit = line.len; + } else { + if (vr >= pane.wrap_n) break; + const lrow: usize = @intCast(@max(0, pane.wrap_line[vr])); + if (lrow >= total) break; + base = lineStart(p.gpa, f, lrow); + const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len; + line = f.content[base..lend]; + hs = @intCast(pane.wrap_col[vr]); + limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr]) + @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1]))) + else + line.len; + } var c: usize = 0; - while (hs + c < line.len and config.PREFIX_W + c < tw) : (c += 1) { + while (hs + c < limit and config.PREFIX_W + c < tw) : (c += 1) { const idx = base + hs + c; if (idx < f.highlight_start) continue; const hidx = idx - f.highlight_start; diff --git a/src/pardes.zig b/src/pardes.zig index ad530319..3acf5765 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3505,6 +3505,28 @@ pub const Pane = struct { /// the goal is just being able to read long lines. Byte columns, like the /// rest of the file-pane code. hscroll: i32 = 0, + /// THE WRAP MAP, and the whole of what soft line breaks are: for every body + /// row of the LAST frame, the document line it showed and the byte column + /// of that line the row started at. `wrap_n == 0` says the body was NOT + /// wrapped, i.e. the rows are the lines from `scroll()` down one each — + /// which is exactly what wrapAt/wrapRow below fall back to, so with the + /// toggle off not one reader computes anything it did not compute before. + /// + /// INVALIDATION, the part that rots if nobody says it out loud: written in + /// EXACTLY ONE PLACE, file_pane.bodyText, on every build of a file pane's + /// body. So it is at worst one frame old — which is what a mouse click + /// wants (you click the character you can SEE), and it is fresh for the + /// render passes, every one of which runs after bodyText inside the same + /// renderPane call. Nothing else may write it; a second writer is a second + /// truth, and the first click on a stale row is how you find out. + /// + /// ponytail: a fixed 256 rows. A pane taller than that does not wrap at + /// all — bodyText leaves wrap_n at 0 and clips the way it always did — + /// rather than half-recording a mapping every site here would then have to + /// distrust. Grow the arrays the day a 256-row window turns up. + wrap_line: [256]i32 = undefined, + wrap_col: [256]i32 = undefined, + wrap_n: u16 = 0, sel: [3]Sel = @splat(.{}), /// terminals only: the typed-text buffer standing in for shell rows ovl: ?Ovl = null, @@ -3562,6 +3584,46 @@ pub const Pane = struct { return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset)); } + /// The document position a BODY ROW begins at — `vr` 0 is the first row + /// under the tag. The screen->document half of the wrap map, and the half + /// the mouse asks: a click lands on the character the user can see, which + /// is last frame's arrangement, which is what the map holds. + pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { + if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) + return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; + // unwrapped: rows ARE lines, and the byte column a row starts at is the + // horizontal scroll (always 0 on a terminal, which never has one) + return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; + } + + /// ...and back: the body row `line`:`col` renders on, plus the byte column + /// that row starts at — subtract it from a document column to get a screen + /// one. `row` is -1 when the position is not on screen, which only a + /// wrapped body ever says: unwrapped the arithmetic answers for any line at + /// all and the callers' own bounds checks do the rejecting, as before. + pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { + if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; + var i: u16 = 0; + while (i < pane.wrap_n) : (i += 1) { + if (pane.wrap_line[i] != line) continue; + // the LAST row of a line owns every column past its start, so a + // cursor parked on the trailing newline still has somewhere to draw + if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; + return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; + } + return .{ .row = -1, .at = 0 }; + } + + /// ponytail: `delta` is LOGICAL LINES, wrapped or not — one `j` past the + /// bottom scrolls a whole line even when that line is five screen rows, and + /// a wheel tick or a Ctrl-d page counts lines rather than rows. So a body + /// full of long lines scrolls in jumps, and the view can never sit at the + /// MIDDLE of a wrapped line. That is the ceiling the whole feature buys its + /// smallness with: wrap is render + hit-test and nothing else in the editor + /// knows about it. The upgrade is to make f.scroll a (line, row-within-line) + /// pair, which every reader of it — this, the scrollbar, the syntax window, + /// bodyText, ensureCursorVisible, the gutter click — would then have to + /// learn; do that when scrolling long lines actually annoys someone. fn scrollBy(pane: *Pane, delta: i32) void { if (pane.file) |*f| { const max: i64 = @intCast(file_pane.nlines(pane.gpa, f) -| 1); @@ -3590,9 +3652,22 @@ pub const Pane = struct { pub fn ensureCursorVisible(pane: *Pane) void { // scrolloff margin, shrunk on short panes so the band stays non-empty - const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); - const last = off + @as(i32, pane.rows) - 1; + // A WRAPPED body shows fewer LINES than it has rows, and scrolling is + // still by line — so the bottom of the view is the last line the map + // recorded, not off+rows-1, or every long line would scroll the file + // out from under itself. Clamped to the file's own last line because + // the rows past EOF go on counting, and the margin shrinks with it for + // the same reason the short-pane clamp above exists: a band wider than + // the view has no stable position and the two branches below then take + // turns moving it, one keystroke each, for ever. + var last = off + @as(i32, pane.rows) - 1; + if (pane.wrap_n > 0) if (pane.file) |*f| { + const eof: i32 = @intCast(file_pane.nlines(pane.gpa, f) -| 1); + last = @min(pane.wrap_line[pane.wrap_n - 1], eof); + margin = @min(margin, @divTrunc(@max(0, last - off), 2)); + }; if (pane.cur_row < off + margin) { pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 } else if (pane.cur_row > last - margin) { @@ -3608,8 +3683,11 @@ pub const Pane = struct { } // the horizontal mirror, files only: keep scroll_off columns of // context around the cursor (wheel-driven hscroll is exempt — it - // never moves the cursor, and a cursor move pulls the view back) - if (pane.file != null) { + // never moves the cursor, and a cursor move pulls the view back). + // A wrapped body has nothing to scroll sideways, and its hscroll is + // left ALONE rather than zeroed: turn the wrap back off and the view + // you had is still there. + if (pane.file != null and pane.wrap_n == 0) { const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W)); const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); if (pane.cur_col < pane.hscroll + hmargin) @@ -3855,6 +3933,10 @@ pub const Pardes = struct { /// its first theme. No frame is rendered in that interval. animate_theme_changes: bool = false, colors_on: bool = true, + /// soft line breaks in file panes (the Wrap builtin). Off by default: with + /// it off every row math in here is the one-row-per-line arithmetic it has + /// always been, and nothing on screen moves by a cell. + wrap_on: bool = false, show_debug: bool = false, /// the Crt builtin's CRT post-process, rendered by the gui shell only /// (the tty and web shells never read it) @@ -4731,8 +4813,9 @@ pub const Pardes = struct { /// the word under the modal cursor as a pane-local selection (paneText /// coords: row 0 is the tag; file panes carry the line-number prefix) fn cursorWordSel(pane: *Pane) Sel { - const vrow = (pane.cur_row - pane.scroll()) + @as(i32, BOX_H); - const vcol = if (pane.file != null) pane.cur_col - pane.hscroll + @as(i32, config.PREFIX_W) else pane.cur_col; + const w = pane.wrapRow(pane.cur_row, pane.cur_col); + const vrow = w.row + @as(i32, BOX_H); + const vcol = if (pane.file != null) pane.cur_col - w.at + @as(i32, config.PREFIX_W) else pane.cur_col; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } @@ -8539,7 +8622,8 @@ pub const Pardes = struct { p.pdfHorizontalWheel(pane, if (m.button == .wheel_right) 1 else -1); // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back - } else if (pane.file != null) { + } else if (pane.file != null and !p.wrap_on) { + // wrapped there is nothing off to the right to reach pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols)); } }, @@ -8874,8 +8958,13 @@ pub const Pardes = struct { if (!dragged) pane.sel[sel_slot].state = .none; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { - pane.cur_row = body_vis + pane.scroll(); - pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, config.PREFIX_W)) + pane.hscroll else sl.c1; + // the row the user clicked, as the LAST FRAME drew it: + // which line it showed and the byte column it started + // at, so a click lands on the character under the + // pointer whether or not that row is a continuation + const w = pane.wrapAt(body_vis); + pane.cur_row = w.line; + pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, config.PREFIX_W)) + w.at else sl.c1; pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; @@ -8900,9 +8989,10 @@ pub const Pardes = struct { // position, the way the left button converts its drag end. const clk = pane.sel[b]; const cvis = clk.r0 - @as(i32, BOX_H); - const crow = cvis + pane.scroll(); + const cw = pane.wrapAt(cvis); + const crow = cw.line; const ccol = if (pane.file != null) - @max(0, clk.c0 - @as(i32, config.PREFIX_W)) + pane.hscroll + @max(0, clk.c0 - @as(i32, config.PREFIX_W)) + cw.at else clk.c0; // A click INSIDE a live selection takes the WHOLE selection @@ -9045,11 +9135,14 @@ pub const Pardes = struct { const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; const pfx: i32 = if (pane.file != null) config.PREFIX_W else 0; - const hs: i32 = if (pane.file != null) pane.hscroll else 0; - const row0 = @max(0, sl.r0 - @as(i32, BOX_H)) + pane.scroll(); - const row1 = @max(0, sl.r1 - @as(i32, BOX_H)) + pane.scroll(); - const col0 = @max(0, sl.c0 - pfx) + hs; - const col1 = @max(0, sl.c1 - pfx) + hs; + // both ends of the sweep through the same last-frame map, so a + // chord over wrapped rows cuts the text that was under it + const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H))); + const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H))); + const row0 = w0.line; + const row1 = w1.line; + const col0 = @max(0, sl.c0 - pfx) + w0.at; + const col1 = @max(0, sl.c1 - pfx) + w1.at; pane.cur_row = row1; pane.cur_col = col1; pane.cur_pinned = true; @@ -11859,11 +11952,15 @@ pub const Pardes = struct { } // modal line selection (helix `x`): whole body rows, normal mode only if (pane.mode == .normal and pane.msel.active) { - const lo = @min(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H); - const hi = @max(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H); + const lo = @min(pane.msel.r0, pane.msel.r1); + const hi = @max(pane.msel.r0, pane.msel.r1); var row: u16 = BOX_H; // never paint the tag row while (row < r.h) : (row += 1) { - if (@as(i32, row) < lo or @as(i32, row) > hi) continue; + // walked by SCREEN row and asked what LINE each one shows, + // because a wrapped line is several rows. wrapAt degenerates to + // `off + row` when nothing wrapped, which is what this was. + const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line; + if (ar < lo or ar > hi) continue; var col: u16 = if (pane.file != null) config.PREFIX_W else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, r.y + row); @@ -11890,7 +11987,6 @@ pub const Pardes = struct { const show_extra = pane.mode != .tty and pane.nsel > 0; if (show_prim or show_extra) { const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0; - const vhs: i32 = if (pane.file != null) pane.hscroll else 0; var si: usize = 0; while (si <= pane.nsel) : (si += 1) { const primary = si == pane.nsel; @@ -11912,12 +12008,19 @@ pub const Pardes = struct { // is readable on the themes whose selection is already close to // the page. const bg = if (primary) th.sel_bg else mix(th.sel_bg, page_rgb); - var ar: i32 = bnd.lo_row; - while (ar <= bnd.hi_row) : (ar += 1) { - const prow = ar - off + @as(i32, BOX_H); - if (prow < BOX_H or prow >= @as(i32, r.h)) continue; - const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col - vhs + vpfx else vpfx; - const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + vpfx else @as(i32, tw) - 1; + // ...and this walks SCREEN rows too, asking the map which line + // and which byte column of it each one shows. Unwrapped that is + // `off + vr` / `hscroll`, i.e. the arithmetic this was, and it + // is also the cheaper loop: a linewise selection over a whole + // file used to iterate once per LINE to reject all but a + // screenful of them. + var vr: i32 = 0; + while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) { + const w = pane.wrapAt(vr); + if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; + const prow = vr + @as(i32, BOX_H); + const cstart: i32 = if (w.line == bnd.lo_row) bnd.lo_col - w.at + vpfx else vpfx; + const cend: i32 = if (w.line == bnd.hi_row) bnd.hi_col - w.at + vpfx else @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); while (col <= cend and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow))); @@ -11927,8 +12030,9 @@ pub const Pardes = struct { } } if (primary and !preview) continue; // the hardware cursor IS the primary's - const crow = sr.row - off + @as(i32, BOX_H); - const ccol = sr.col - vhs + vpfx; + const cw = pane.wrapRow(sr.row, sr.col); + const crow = cw.row + @as(i32, BOX_H); + const ccol = sr.col - cw.at + vpfx; if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) { const cell = s.at(tx + @as(u16, @intCast(ccol)), r.y + @as(u16, @intCast(crow))); cell.default = false; @@ -11946,10 +12050,14 @@ pub const Pardes = struct { const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); - const prow = crow - off + @as(i32, BOX_H); + // which ROW of a wrapped line the cursor is on, and which byte + // column that row starts at — `off`/`hscroll` when nothing + // wrapped, so this is the same two lines it always was + const cwp = pane.wrapRow(crow, ccol); + const prow = cwp.row + @as(i32, BOX_H); // files: cur_col is content-relative, add the lineno prefix const cx = if (pane.file != null) - @as(i32, config.PREFIX_W) + ccol - pane.hscroll + @as(i32, config.PREFIX_W) + ccol - cwp.at else ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) @@ -12253,7 +12361,7 @@ pub const Pardes = struct { } return out.items; } - if (pane.file) |*f| return file_pane.bodyText(arena, pane, f); + if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.wrap_on); return term_pane.bodyText(arena, pane); } }; diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden index 8bb8d035..36a7b2d9 100644 --- a/test/snapshots/builtins.golden +++ b/test/snapshots/builtins.golden @@ -153,7 +153,6 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help New Del -| 23 SPC l h SelectRefs | 24 SPC l i Lspinfo | 25 SPC l k Hover | 26 SPC l r Rename @@ -171,16 +170,17 @@ | 38 SPC t r Crt | 39 SPC t s PdfSections | 40 SPC t t ThemeSel -| 41 SPC t z PdfFit -| 42 SPC w h Left C-w h, C-w left -| 43 SPC w j Down C-w j, C-w down -| 44 SPC w k Up C-w k, C-w up -| 45 SPC w l Right C-w l, C-w right -| 46 SPC w t Toggleterm -| 47 Look enter, right-click -| 48 Exec tab, middle-click -| 49 Theme -| 50 +| 41 SPC t w Wrap +| 42 SPC t z PdfFit +| 43 SPC w h Left C-w h, C-w left +| 44 SPC w j Down C-w j, C-w down +| 45 SPC w k Up C-w k, C-w up +| 46 SPC w l Right C-w l, C-w right +| 47 SPC w t Toggleterm +| 48 Look enter, right-click +| 49 Exec tab, middle-click +| 50 Theme +| 51 == snap not-a-picker grid=120x60 cursor=7,58 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del @@ -214,7 +214,6 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help New Del -| 23 SPC l h SelectRefs | 24 SPC l i Lspinfo | 25 SPC l k Hover | 26 SPC l r Rename @@ -232,16 +231,17 @@ | 38 SPC t r Crt | 39 SPC t s PdfSections | 40 SPC t t ThemeSel -| 41 SPC t z PdfFit -| 42 SPC w h Left C-w h, C-w left -| 43 SPC w j Down C-w j, C-w down -| 44 SPC w k Up C-w k, C-w up -| 45 SPC w l Right C-w l, C-w right -| 46 SPC w t Toggleterm -| 47 Look enter, right-click -| 48 Exec tab, middle-click -| 49 Theme -| 50 +| 41 SPC t w Wrap +| 42 SPC t z PdfFit +| 43 SPC w h Left C-w h, C-w left +| 44 SPC w j Down C-w j, C-w down +| 45 SPC w k Up C-w k, C-w up +| 46 SPC w l Right C-w l, C-w right +| 47 SPC w t Toggleterm +| 48 Look enter, right-click +| 49 Exec tab, middle-click +| 50 Theme +| 51 == snap window-group grid=120x60 cursor=7,32 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden index db443b80..c33b1633 100644 --- a/test/snapshots/leader.golden +++ b/test/snapshots/leader.golden @@ -402,9 +402,9 @@ | 10 SPC t r Crt | 11 SPC t s PdfSections | 12 SPC t t ThemeSel -| 13 SPC t z PdfFit -| 14 -| +| 13 SPC t w Wrap +| 14 SPC t z PdfFit +| 15 | | | diff --git a/test/snapshots/wrap.golden b/test/snapshots/wrap.golden new file mode 100644 index 00000000..87b70f68 --- /dev/null +++ b/test/snapshots/wrap.golden @@ -0,0 +1,90 @@ +== snap off grid=47x14 cursor=7,2 +|New Newcol Find Grep Help Tutor Dump NextColor +| /tmp/pardes-snap/wrap/cwd/long.txt Save New D +| 1 0---------1---------2---------3--------- +| 2 second line +| 3 third line +| 4 +| +| +| +| +| +| +| +| +== snap on-narrow grid=47x14 cursor=7,2 +|New Newcol Find Grep Help Tutor Dump NextColor +| /tmp/pardes-snap/wrap/cwd/long.txt Save New D +| 1 0---------1---------2---------3--------- +| 4---------5---------6---------7--------- +| 8---------9---------A---------B--------- +| C---------D---------E---------F--------- +| G---------H---------I---------J--------- +| 2 second line +| 3 third line +| 4 +| +| +| +| +== snap clicked grid=47x14 cursor=17,3 +|New Newcol Find Grep Help Tutor Dump NextColor +| /tmp/pardes-snap/wrap/cwd/long.txt Save New D +| 1 0---------1---------2---------3--------- +| 4---------5---------6---------7--------- +| 8---------9---------A---------B--------- +| C---------D---------E---------F--------- +| G---------H---------I---------J--------- +| 2 second line +| 3 third line +| 4 +| +| +| +| +== snap logical-j grid=47x14 cursor=18,7 +|New Newcol Find Grep Help Tutor Dump NextColor +| /tmp/pardes-snap/wrap/cwd/long.txt Save New D +| 1 0---------1---------2---------3--------- +| 4---------5---------6---------7--------- +| 8---------9---------A---------B--------- +| C---------D---------E---------F--------- +| G---------H---------I---------J--------- +| 2 second line +| 3 third line +| 4 +| +| +| +| +== snap off-again grid=47x14 cursor=18,3 +|New Newcol Find Grep Help Tutor Dump NextColor +| /tmp/pardes-snap/wrap/cwd/long.txt Save New D +| 1 0---------1---------2---------3--------- +| 2 second line +| 3 third line +| 4 +| +| +| +| +| +| +| +| +== snap on-wide grid=127x14 cursor=7,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/wrap/cwd/long.txt Save New Del +| 1 0---------1---------2---------3---------4---------5---------6---------7---------8---------9--------- +| A---------B---------C---------D---------E---------F---------G---------H---------I---------J--------- +| 2 second line +| 3 third line +| 4 +| +| +| +| +| +| +| diff --git a/test/snapshots/wrap.snap b/test/snapshots/wrap.snap new file mode 100644 index 00000000..c6e976b1 --- /dev/null +++ b/test/snapshots/wrap.snap @@ -0,0 +1,54 @@ +# Soft line breaks (the Wrap builtin, `SPC t w`): a file line too long for the +# pane renders across several body rows instead of running off the right edge. +# OFF by default, and off it is the clipping render it has always been. +# +# Three rules pinned here. The break is at min(100, the pane's own content +# width), so a narrow pane breaks at its own edge and a wide one stops at 100. +# A continuation row carries a BLANK gutter — the number belongs to the LINE, +# and a number repeated down a wrapped line would read as several lines. And +# the mouse follows the render: a click on a continuation row lands on the +# character under the pointer, which only works if the click resolves through +# the same row->line map the body was drawn from. +# +# The line is 20 decades, each headed by its own column marker: `0` at column +# 0, `4` at 40, `A` at 100. So the first character of a continuation row NAMES +# the column it broke at, and the width rule is readable straight off the grid. +file long.txt 0---------1---------2---------3---------4---------5---------6---------7---------8---------9---------A---------B---------C---------D---------E---------F---------G---------H---------I---------J---------\nsecond line\nthird line\n +# 47 columns: the 2-column chrome gutter plus the 5-column line-number gutter +# leave 40 for content, under 100, so the PANE's width is the break. +start 14 47 long.txt +wait 8000 New Newcol +stable 700 20000 +# off: line 1 on ONE row, cut at the right edge, lines 2 and 3 under it +snap off +key space t w +stable 400 5000 +# on: five rows for line 1 (200 bytes / 40), the four continuations gutterless +# and starting at `4` `8` `C` `G` — the columns 40, 80, 120, 160 +snap on-narrow +# a click on the SECOND row of line 1, on the `5` marker at column 50. The +# cursor must come back to the cell that was clicked (grid 17,3): the click +# resolved to line 1 column 50, and the render put line 1 column 50 back where +# the finger was. Off by one row and it would land in line 2 instead. +press left 18 4 +release left 18 4 +stable 400 5000 +snap clicked +# motions stay LOGICAL: one `j` from line 1 leaves the whole wrapped line +# behind and lands on line 2, four screen rows down at once. +key j +stable 400 5000 +snap logical-j +# ...and off again is off: the same clipped body as the first snap, because +# the map the readers consult goes back to empty and every one of them falls +# back to the row arithmetic it used before any of this existed +key space t w +stable 400 5000 +snap off-again +# 127 columns: 120 for content, so the 100 wins and the break is at the `A` +start 14 127 long.txt +wait 8000 New Newcol +stable 700 20000 +key space t w +stable 400 5000 +snap on-wide |
