Skip to content

Commit 4b1a0ba

Browse files
emil-eclaude
andcommitted
Replace rotation-detection hash with direct row snapshot
The FNV-1a hash over the first 16 cells added complexity with no benefit. At 30 fps the entire operation — viewport scroll, row read, comparison — is trivially cheap, so there was never a performance concern to optimise around. Hashing also introduced a small collision probability and an arbitrary 16-cell sample that could miss rotation when two different rows share the same opening cells. Replace `first_scrollback_row_hash: u64` with `first_scrollback_row: [512]u32` + `first_scrollback_row_valid: bool`, scan all `term.cols` cells, and compare with `std.mem.eql`. The cached-read optimisation (skip the end-of-redraw round trip when start-of-redraw already confirmed no rotation) is preserved: when `cached_row0_valid` is set, `term.first_scrollback_row` already holds the current value so no second read is needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 257386d commit 4b1a0ba

2 files changed

Lines changed: 50 additions & 53 deletions

File tree

src/render.zig

Lines changed: 42 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -464,21 +464,23 @@ pub fn isRowEmptyAt(term: *Terminal, cy: u16) bool {
464464
return true;
465465
}
466466

467-
/// Hash the first ~16 cells of libghostty's first scrollback row using
468-
/// FNV-1a. Returns 0 if there is no scrollback or if anything fails.
467+
/// Read the first scrollback row's codepoints into `out` (one per cell,
468+
/// up to 512 entries). Returns true on success, false if there is no
469+
/// scrollback or anything fails. For terminals wider than 512 columns
470+
/// only the first 512 cells are compared — a practical limit since
471+
/// 512 columns exceeds any standard display.
469472
///
470473
/// Used to detect rotation: when libghostty's scrollback is plateaued at
471474
/// its byte cap, sustained writes evict the oldest row in lockstep with
472475
/// new rows being pushed, so `total_rows` doesn't change and the normal
473-
/// delta-detection sees no work to do. Sampling the first scrollback
474-
/// row's content lets us detect that the row at index 0 has changed
475-
/// underneath us.
476+
/// delta-detection sees no work to do. Comparing the full row lets us
477+
/// detect that the row at index 0 has changed underneath us.
476478
///
477479
/// Scrolls libghostty's viewport to the top to read the row, then
478-
/// restores the previous viewport offset. Cheap (~6 libghostty calls);
479-
/// gated by the caller to only run when rotation is suspected.
480-
fn computeFirstScrollbackRowHash(term: *Terminal) u64 {
481-
const sb = term.getScrollbar() orelse return 0;
480+
/// restores the previous viewport offset. Gated by the caller to only
481+
/// run when rotation is suspected.
482+
fn readFirstScrollbackRow(term: *Terminal, out: *[512]u32) bool {
483+
const sb = term.getScrollbar() orelse return false;
482484
const saved_offset = sb.offset;
483485

484486
term.scrollViewport(gt.SCROLL_TOP, 0);
@@ -489,29 +491,23 @@ fn computeFirstScrollbackRowHash(term: *Terminal) u64 {
489491
}
490492
}
491493

492-
if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return 0;
493-
if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) return 0;
494-
if (!gt.c.ghostty_render_state_row_iterator_next(term.row_iterator)) return 0;
495-
if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_CELLS, @ptrCast(&term.row_cells)) != gt.SUCCESS) return 0;
494+
if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return false;
495+
if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) return false;
496+
if (!gt.c.ghostty_render_state_row_iterator_next(term.row_iterator)) return false;
497+
if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_CELLS, @ptrCast(&term.row_cells)) != gt.SUCCESS) return false;
496498

497-
// FNV-1a 64-bit hash. We mix in the first ~16 cells' first
498-
// codepoints (or a space for empty cells) — enough entropy to
499-
// distinguish rotation states without scanning the whole row.
500-
const fnv_prime: u64 = 0x100000001b3;
501-
var hash: u64 = 0xcbf29ce484222325;
499+
@memset(out, 0);
502500
var i: usize = 0;
503-
while (i < 16 and gt.c.ghostty_render_state_row_cells_next(term.row_cells)) : (i += 1) {
501+
const cols = @min(term.cols, 512);
502+
while (i < cols and gt.c.ghostty_render_state_row_cells_next(term.row_cells)) : (i += 1) {
504503
var graphemes_len: u32 = 0;
505504
if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_LEN, @ptrCast(&graphemes_len)) != gt.SUCCESS) continue;
506-
if (graphemes_len == 0) {
507-
hash = (hash ^ ' ') *% fnv_prime;
508-
continue;
509-
}
505+
if (graphemes_len == 0) continue;
510506
var codepoints: [4]u32 = undefined;
511507
if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_BUF, @ptrCast(&codepoints)) != gt.SUCCESS) continue;
512-
hash = (hash ^ codepoints[0]) *% fnv_prime;
508+
out[i] = codepoints[0];
513509
}
514-
return hash;
510+
return true;
515511
}
516512

517513
/// Result from buildRowContent: byte length for make_string, char count for properties.
@@ -976,20 +972,22 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
976972
// that sampling requires is wasted work if we are about to erase anyway.
977973
// On a hash match, stash the value for reuse at end-of-redraw (promotion
978974
// and insert-at-tail don't shift row 0, so it stays valid).
979-
var cached_row0_hash: ?u64 = null;
975+
var cached_row0_valid = false;
980976
if (!scrollback_stale and
981977
term.wrote_since_redraw and
982978
term.scrollback_in_buffer > 0 and
983-
term.first_scrollback_row_hash != 0)
979+
term.first_scrollback_row_valid)
984980
{
985-
const new_hash = computeFirstScrollbackRowHash(term);
986-
// computeFirstScrollbackRowHash scrolled libghostty's viewport to
981+
var new_row: [512]u32 = undefined;
982+
const read_ok = readFirstScrollbackRow(term, &new_row);
983+
// readFirstScrollbackRow scrolled libghostty's viewport to
987984
// sample row 0; the render state is now stale — refresh it.
988985
if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return;
989-
if (new_hash != term.first_scrollback_row_hash) {
986+
const compare_cols = @min(term.cols, 512);
987+
if (read_ok and !std.mem.eql(u32, new_row[0..compare_cols], term.first_scrollback_row[0..compare_cols])) {
990988
scrollback_stale = true;
991-
} else {
992-
cached_row0_hash = new_hash;
989+
} else if (read_ok) {
990+
cached_row0_valid = true;
993991
}
994992
}
995993

@@ -1004,7 +1002,7 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
10041002
if (scrollback_stale) {
10051003
env.eraseBuffer();
10061004
term.scrollback_in_buffer = 0;
1007-
term.first_scrollback_row_hash = 0;
1005+
term.first_scrollback_row_valid = false;
10081006
force_full = true;
10091007
}
10101008

@@ -1331,24 +1329,21 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
13311329
_ = env.call1(emacs.sym.@"ghostel--update-directory", env.makeString(pwd));
13321330
}
13331331

1334-
// Update the cached first-scrollback-row hash for the next redraw's
1335-
// rotation check. Reuse the start-of-redraw hash when it's still
1336-
// valid (no rotation, no trim) — avoids a second scroll-to-top +
1337-
// render_state_update round trip per redraw.
1338-
//
1339-
// If nothing was written AND we didn't populate `cached_row0_hash`
1340-
// at the top, row 0 cannot have moved since the previous redraw
1341-
// set `first_scrollback_row_hash`, so skip the compute entirely.
1342-
// This covers cursor-only redraws and idle-timer fires.
1332+
// Update the cached first-scrollback-row snapshot for the next redraw's
1333+
// rotation check. When the start-of-redraw check found no rotation,
1334+
// `term.first_scrollback_row` already holds the current value — skip
1335+
// the second round trip. If nothing was written at all, row 0 cannot
1336+
// have moved, so skip entirely. This covers cursor-only redraws and
1337+
// idle-timer fires.
13431338
if (term.scrollback_in_buffer > 0) {
1344-
if (cached_row0_hash) |h| {
1345-
term.first_scrollback_row_hash = h;
1339+
if (cached_row0_valid) {
1340+
// term.first_scrollback_row is already current; nothing to do.
13461341
} else if (term.wrote_since_redraw) {
1347-
term.first_scrollback_row_hash = computeFirstScrollbackRowHash(term);
1342+
term.first_scrollback_row_valid = readFirstScrollbackRow(term, &term.first_scrollback_row);
13481343
}
1349-
// else: no writes, no cached hash → existing value is still current.
1344+
// else: no writes, no cached row → existing value is still current.
13501345
} else {
1351-
term.first_scrollback_row_hash = 0;
1346+
term.first_scrollback_row_valid = false;
13521347
}
13531348

13541349
// Clear the write flag so the next redraw can detect "writes happened

src/terminal.zig

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@ rebuild_pending: bool = false,
5959
/// new.
6060
last_input_was_cr: bool = false,
6161

62-
/// Hash of the first scrollback row's content, sampled at the end of
63-
/// each redraw that touched scrollback. Used to detect rotation
64-
/// (libghostty evicting the oldest row in lockstep with new ones being
65-
/// pushed) when `total_rows` is plateaued at the cap. Zero means "no
66-
/// scrollback" or "not yet sampled".
67-
first_scrollback_row_hash: u64 = 0,
62+
/// Snapshot of the first scrollback row's codepoints (one per cell, up
63+
/// to `cols` entries), sampled at the end of each redraw that touched
64+
/// scrollback. Used to detect rotation (libghostty evicting the oldest
65+
/// row in lockstep with new ones being pushed) when `total_rows` is
66+
/// plateaued at the cap. Only meaningful when `first_scrollback_row_valid`
67+
/// is true.
68+
first_scrollback_row: [512]u32 = [_]u32{0} ** 512,
69+
first_scrollback_row_valid: bool = false,
6870

6971
/// Cached Emacs env pointer — only valid during a callback from Emacs.
7072
env: ?emacs.Env = null,

0 commit comments

Comments
 (0)