From 1891a51ed2b64eec3964d3af3807be90d08a14c2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 5 Aug 2026 15:57:48 -0300 Subject: soft-wrap long lines behind a toggle --- src/pardes.zig | 170 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 139 insertions(+), 31 deletions(-) (limited to 'src/pardes.zig') 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); } }; -- cgit v1.3