From 9a256d34ebca317e3007adf71c2b94016059d4a3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 18:40:41 -0300 Subject: A selection deep in a long line costs a render what one at its start does: 5 MB in, /screen goes from 14.7 s to 5 ms Every render measured a selection's and the cursor's columns from the start of their line (lineDisplayOffset walked the whole prefix twice), so a selection near the end of a 5 MB line stalled each frame, 3 s at 1 MB in and 14.7 s at 5 MB. The cost grew with the offset, with Wrap on or off. Three changes make it independent of where the selection is: - display width adds (a tab is tab_width wherever it stands), so the offset between two columns is the width of the text between them, never the prefix; - the selection's rows are clamped to what is shown: a row wholly before the selection is skipped, a start before the row's first column starts there, and an end past its last column ends there; - a head or cursor whose column lies past its visible row (the last visible row of a line owns every column after its start) is not measured, since it is not drawn. Measured on a Debug build, 5 MB line: offset 10, 1 MB and 5 MB now take 11, 6 and 5 ms per /screen. A new perf gate case, deep-sel, renders with the selection 2 MB into a line: 83 us on Debug. The baselines were re-recorded for the new harness. Co-Authored-By: Claude Opus 5.5 --- src/File.zig | 15 +++++++++++---- src/body_layer.zig | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/File.zig b/src/File.zig index 78c84287..a89db613 100644 --- a/src/File.zig +++ b/src/File.zig @@ -352,11 +352,18 @@ pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: us return from +| rawAtDisplay(line_text[from..], display_col); } +/// The display columns from `from_raw` to `to_raw` on a line. Widths add +/// (a tab is `tab_width` wherever it stands), so only the text between +/// the two is measured, never the line from its start: a selection a +/// million bytes into a line draws as fast as one at its start. pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 { - const from_display = rawDisplayCol(line_text, from_raw); - const to_display = rawDisplayCol(line_text, to_raw); - if (to_display >= from_display) return @intCast(to_display - from_display); - return -@as(i32, @intCast(from_display - to_display)); + const lo = @min(from_raw, to_raw); + const hi = @max(from_raw, to_raw); + const a = modal.graphemeStart(line_text, @min(lo, line_text.len)); + const b = modal.graphemeStart(line_text, @min(hi, line_text.len)); + const past = (hi -| line_text.len) - (lo -| line_text.len); + const width: usize = displayWidth(line_text[a..b]) +| past; + return if (to_raw >= from_raw) @intCast(width) else -@as(i32, @intCast(width)); } pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 { diff --git a/src/body_layer.zig b/src/body_layer.zig index 8e7cd030..914694e7 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -83,6 +83,17 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [ } } +/// Whether `col` lies past the last column its visible row shows: the last +/// visible row of a line owns every column after its start (wrapRow), so a +/// head millions of bytes along a long line is on that row, off the view. +fn pastView(pane: *Pane, body: []const u8, line: i32, row: i32, at: i32, col: i32, tw: u16) bool { + const text = if (pane.file != null) panes.File.sourceLine(pane, line) else modal.lineSlice(body, @intCast(@max(0, row))); + const from: usize = @intCast(@max(0, at)); + const to: usize = @intCast(@max(0, col)); + if (from >= text.len) return to >= from +| tw; + return to >= from + panes.File.byteAtDisplay(text[from..], tw) and to < text.len; +} + fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { if (pane.isTerminal()) return paintTerminalSelection(p, s, pane, r, rows, bg, fg); const terminal_lines = if (pane.file == null) panes.terminal.cursorLines(p, pane) catch return else &.{}; @@ -371,14 +382,26 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane const w = pane.wrapAt(vr); if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; const visible_line = modal.lineSlice(body, @intCast(vr)); - const cstart: i32 = if (w.line == bnd.lo_row) + // Only the visible columns are measured: a selection that + // starts before this row's first column starts at it, and + // one that ends past its last ends there. + const row_text = if (pane.file != null) panes.File.sourceLine(pane, w.line) else visible_line; + const row_at: usize = @intCast(@max(0, w.at)); + // The byte past the row's last column, walked from its first: + // bounded by the view, not the line. + // A line that ends inside the view has no column past it. + const shown_to = if (row_at >= row_text.len) row_text.len else row_at + panes.File.byteAtDisplay(row_text[row_at..], tw); + const past_view = if (shown_to >= row_text.len) std.math.maxInt(usize) else shown_to; + // A row that ends before the selection starts has none of it. + if (w.line == bnd.lo_row and @as(usize, @intCast(@max(0, bnd.lo_col))) >= past_view) continue; + const cstart: i32 = if (w.line == bnd.lo_row and bnd.lo_col > w.at) (if (pane.file != null) panes.File.displayOffset(pane, w.line, w.at, bnd.lo_col) else panes.File.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx else vpfx; - const cend: i32 = if (w.line == bnd.hi_row) + const cend: i32 = if (w.line == bnd.hi_row and @as(usize, @intCast(@max(0, bnd.hi_col))) < past_view) (if (pane.file != null) panes.File.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else @@ -396,6 +419,10 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's if (hover_only) continue; // quiet preview preserves the source ink const cw = pane.wrapRow(sr.row, sr.col); + // A head off the view is measured not at all: its row is not + // drawn, and its column may be millions of bytes along. + if (cw.row < 0 or cw.row + @as(i32, pane.tag_rows) >= @as(i32, body_bottom)) continue; + if (pastView(pane, body, sr.row, cw.row, cw.at, sr.col, tw)) continue; const crow = cw.row + @as(i32, pane.tag_rows); const ccol = (if (pane.file != null) panes.File.displayOffset(pane, sr.row, cw.at, sr.col) @@ -447,6 +474,9 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x)); const cwp = pane.wrapRow(crow, ccol); const prow = cwp.row + @as(i32, pane.tag_rows); + // Off the view, the cursor is not measured (its column may be + // millions of bytes along a line whose start is shown). + if (cwp.row >= 0 and prow < body_bottom and !pastView(pane, body, crow, cwp.row, cwp.at, ccol, tw)) { // Files store source-byte columns; the Surface stores display // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) @@ -461,6 +491,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane ccol; if (prow >= pane.tag_rows and cx >= 0 and prow < body_bottom and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - pane.tag_rows)), .bar = pane.body.mode == .insert }; + } } else if (panes.terminal.visibleCursor(pane)) |cur| { if (cur.y + pane.tag_rows < body_bottom and cur.x < tw) s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; -- cgit v1.3