summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig170
1 files changed, 139 insertions, 31 deletions
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);
}
};