@@ -819,7 +819,6 @@ fn insertScrollbackRange(
819819 return inserted ;
820820}
821821
822-
823822/// Convert a terminal column to an Emacs character offset by iterating
824823/// the row's cells. Returns `true` and positions point on success;
825824/// `false` if the cell data is unavailable (caller should fall back to
@@ -891,8 +890,8 @@ fn positionCursorByCell(env: emacs.Env, term: *Terminal, cx: u16, cy: u16) bool
891890/// On each call we:
892891/// 1. Force libghostty's viewport to the bottom (active screen).
893892/// 2. Poll `getTotalRows()` against `term.scrollback_in_buffer` to
894- /// detect rows that scrolled off the top (append to buffer) or
895- /// rows evicted by libghostty's scrollback cap (trim from buffer).
893+ /// detect rows that scrolled off the top of the viewport and promoted them
894+ /// to the scrollback part of the buffer
896895/// 3. Render the viewport into the tail of the buffer, anchored at
897896/// the line that follows the last scrollback row.
898897///
@@ -936,17 +935,6 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
936935 return ;
937936 }
938937
939- // ---- Deferred resize buffer reset ----------------------------------------
940- // terminal.resize() sets resize_pending instead of immediately erasing the
941- // buffer, so the old frame stays visible until this redraw replaces it
942- // (the caller binds inhibit-redisplay around us). Erase now and force a
943- // full rebuild of scrollback + viewport.
944- if (term .resize_pending ) {
945- env .eraseBuffer ();
946- term .resize_pending = false ;
947- force_full = true ;
948- }
949-
950938 // Resolve default colors once — used for both the scrollback append
951939 // path and the viewport render path. These always succeed (the
952940 // render state always has resolved default colors), so batching is safe.
@@ -964,45 +952,62 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
964952 _ = gt .c .ghostty_render_state_get_multi (term .render_state , color_keys .len , & color_keys , @ptrCast (& color_values ), null );
965953 }
966954
967- // ---- Scrollback rotation detection ------------------------------------
968- // When libghostty's scrollback is at its byte cap, sustained writes
969- // evict the oldest rows and push new ones, so the row at scrollback
970- // index 0 changes underneath us. The normal delta-sync below tracks
971- // `total_rows` deltas, but those don't capture content rotation —
972- // if the count is unchanged (or even shrinking) the trim path would
973- // remove our top rows under the *assumption* they match the rows
974- // libghostty just evicted, which isn't true after rotation.
955+ // ---- Scrollback validity ------------------------------------------------
956+ // Two signals can invalidate buffered scrollback and are collected here;
957+ // a third (rotation) is checked below.
975958 //
976- // Detect rotation by hashing the first scrollback row whenever
977- // writes have happened since the last redraw and we have scrollback.
978- // A change means the top row is no longer the row we materialized
979- // → wipe the buffer and let the delta-sync below re-fetch everything
980- // fresh from libghostty.
959+ // rebuild_pending: set by terminal.resize() and by the CSI 3 J scanner
960+ // in vtWrite. Defers the Emacs-buffer erase into the
961+ // redraw pass where inhibit-redisplay prevents a
962+ // visible blank frame.
981963 //
982- // When the hash matches (no rotation), stash it in `cached_row0_hash`
983- // and reuse at end-of-redraw. Promotion / insert-at-tail don't shift
984- // row 0, so the start-of-redraw hash is still valid. Trim and
985- // rotation-erase invalidate the cache.
964+ // rotation: checked below via a row-0 hash; only computed when
965+ // rebuild_pending hasn't already fired.
966+ var scrollback_stale = term .rebuild_pending ;
967+ term .rebuild_pending = false ;
968+
969+ // ---- Rotation detection ------------------------------------------------
970+ // When libghostty's scrollback cap is saturated, sustained writes evict
971+ // the oldest rows. total_rows doesn't change, so the delta-sync below
972+ // would see nothing to do — detect the churn by hashing the first
973+ // scrollback row and comparing to the value sampled at the last redraw.
974+ //
975+ // Skip when scrollback is already known to be stale: the viewport scroll
976+ // that sampling requires is wasted work if we are about to erase anyway.
977+ // On a hash match, stash the value for reuse at end-of-redraw (promotion
978+ // and insert-at-tail don't shift row 0, so it stays valid).
986979 var cached_row0_hash : ? u64 = null ;
987- if (term .wrote_since_redraw and term .scrollback_in_buffer > 0 and term .first_scrollback_row_hash != 0 ) {
980+ if (! scrollback_stale and
981+ term .wrote_since_redraw and
982+ term .scrollback_in_buffer > 0 and
983+ term .first_scrollback_row_hash != 0 )
984+ {
988985 const new_hash = computeFirstScrollbackRowHash (term );
989986 // computeFirstScrollbackRowHash scrolled libghostty's viewport to
990- // sample row 0 and the defer restored the offset, but the render
991- // state may now be stale — refresh it before continuing.
987+ // sample row 0; the render state is now stale — refresh it.
992988 if (gt .c .ghostty_render_state_update (term .render_state , term .terminal ) != gt .SUCCESS ) return ;
993989 if (new_hash != term .first_scrollback_row_hash ) {
994- // Rotation detected — erase the buffer entirely and force a
995- // full viewport render. The delta-sync below will then see
996- // libghostty_sb - 0 = libghostty_sb and refetch everything.
997- env .eraseBuffer ();
998- term .scrollback_in_buffer = 0 ;
999- term .first_scrollback_row_hash = 0 ;
1000- force_full = true ;
990+ scrollback_stale = true ;
1001991 } else {
1002992 cached_row0_hash = new_hash ;
1003993 }
1004994 }
1005995
996+ // Compare counts: scrollback shrinking means the buffered rows are no
997+ // longer valid (CSI 3 J or resize would have set rebuild_pending, but
998+ // this catches any other unexpected reduction).
999+ const total_rows = term .getTotalRows ();
1000+ const libghostty_sb : usize = if (total_rows > term .rows ) total_rows - term .rows else 0 ;
1001+ if (libghostty_sb < term .scrollback_in_buffer ) scrollback_stale = true ;
1002+
1003+ // If scrollback is stale for any reason, erase it completely.
1004+ if (scrollback_stale ) {
1005+ env .eraseBuffer ();
1006+ term .scrollback_in_buffer = 0 ;
1007+ term .first_scrollback_row_hash = 0 ;
1008+ force_full = true ;
1009+ }
1010+
10061011 // ---- Scrollback sync ---------------------------------------------------
10071012 // libghostty stores scrollback + active screen in a single row space.
10081013 // The rows "above" the viewport are scrollback; our invariant is that
@@ -1012,8 +1017,6 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
10121017 // by walking from point-min and reusing point() after any insert/trim
10131018 // touches it. forwardLine is O(scrollback) so doing it twice would
10141019 // double the per-redraw cost in long-running sessions.
1015- const total_rows = term .getTotalRows ();
1016- const libghostty_sb : usize = if (total_rows > term .rows ) total_rows - term .rows else 0 ;
10171020
10181021 // Walk to the current viewport start (line scrollback_in_buffer + 1).
10191022 env .gotoCharN (1 );
@@ -1102,31 +1105,6 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
11021105 term .scrollViewport (gt .SCROLL_BOTTOM , 0 );
11031106 if (gt .c .ghostty_render_state_update (term .render_state , term .terminal ) != gt .SUCCESS ) return ;
11041107 }
1105- } else if (libghostty_sb < term .scrollback_in_buffer ) {
1106- // libghostty's scrollback cap evicted the oldest rows — trim the
1107- // same number of lines from the top of the buffer. Trim shifts
1108- // row 0 so the start-of-redraw hash is stale.
1109- cached_row0_hash = null ;
1110- const delta = term .scrollback_in_buffer - libghostty_sb ;
1111- env .gotoCharN (1 );
1112- if (env .forwardLine (@as (i64 , @intCast (delta ))) == 0 ) {
1113- env .deleteRegion (env .makeInteger (1 ), env .point ());
1114- term .scrollback_in_buffer -= delta ;
1115- // After the delete, the new viewport start has shifted down.
1116- // forwardLine is already at the right line (point-min + delta
1117- // lines was the next surviving row, now line 1+scrollback_in_buffer).
1118- // Recompute by walking the remaining scrollback rows.
1119- } else {
1120- // Ran off the end — buffer is out of sync; rebuild from scratch.
1121- env .eraseBuffer ();
1122- term .scrollback_in_buffer = 0 ;
1123- force_full = true ;
1124- }
1125- env .gotoCharN (1 );
1126- if (term .scrollback_in_buffer > 0 ) {
1127- _ = env .forwardLine (@as (i64 , @intCast (term .scrollback_in_buffer )));
1128- }
1129- viewport_start_int = env .extractInteger (env .point ());
11301108 }
11311109
11321110 // Check dirty state — cells are only redrawn when dirty, but cursor
0 commit comments