Skip to content

Commit 2efecf2

Browse files
committed
Preserve scroll position across window resize (M-x, etc.)
Scrolling up to read terminal history and then pressing M-x (or any command that resizes the ghostel window) previously yanked the view back to the prompt. Three root causes conspired: 1. The post-redraw anchor check keyed off `point', which is always at `point-max' after the native redraw — so it always snapped the window down unless `saved-marker' happened to restore point first. 2. Byte-position markers don't survive the native redraw's `eraseBuffer' + re-insert on full redraw / resize. 3. Emacs' own redisplay can clamp `window-start' to `point-min' between our delayed-redraws (typically after a minibuffer open/close cycle). Fix: classify windows as auto-follow vs. user-scrolled by comparing `window-start' to a recorded `ghostel--last-anchor-position'. Track scrolled positions by multi-line content keys (`ghostel--line-key', 3 consecutive lines) so they survive arbitrary buffer reshuffles — scrollback eviction, viewport rewrite, full redraw erase — and aren't fooled by blank lines or repeated prompts. A pre-redraw pass restores any window whose `window-start' or `window-point' has been clamped to `point-min' while the saved key pointed elsewhere (`ghostel--position-mangled-p'). Other position changes are left alone — the post-redraw capture rebuilds `ghostel--scroll-positions' from each window's live state. Reset scroll state in `ghostel-clear-scrollback' and `ghostel-copy-mode-exit' so stale entries don't point into rewritten content. copy-mode-exit also sets `ghostel--force-next-redraw' so the snap fires under DEC 2026 synchronized output. Trade-off: opted for this heuristic over a `post-command-hook' that would fire every keystroke. Known limitation (documented + tested): if Emacs moves ws to a non-`point-min' non-saved position (programmatic `recenter', `follow-mode'), the pass treats it as a user scroll. Also fixes Claude Code TUI flicker: typing into a TUI that re-renders per keystroke caused a 1-row jump-down/jump-back-up oscillation. Two contributors: 1. `ghostel--snap-to-input' did `(goto-char (point-max))' on every self-insert / send-event / paste. Emacs' redisplay then pulled `window-point' along, saw it at `window-end', and `scroll-conservatively-101' bumped `window-start' forward by a row. The next ghostel redraw reset it — flicker. With `ghostel--snap-requested' driving anchoring regardless of point, the goto is redundant; remove it. 2. `ghostel--viewport-start' computed viewport from `point-max' via `forward-line -(tr-1)'. When the buffer ends with a trailing newline (left by partial-redraw trim), the walk lands one line too deep and clips row 1. Step past a trailing `\n' first. Refactored `ghostel--delayed-redraw' (was 149 lines) into an orchestrator plus focused helpers: `ghostel--viewport-start', `--window-anchored-p', `--capture-window-state', `--position-mangled-p', `--reconcile-saved-position', `--correct-mangled-scroll-positions', `--anchor-window', `--restore-scrollback-window'. `ghostel-debug.el' now snapshots viewport / anchor / per-window ws/we/wp state around each redraw so oscillation patterns are visible from the log. New tests cover: scroll preservation across the real resize path (`ghostel--window-adjust-process-window-size'), live output while scrolled, blank-line disambiguation, multi-window mixed states, clear / copy-mode state reset (incl. `force-next-redraw'), OSC 51;E window-point sync, first-redraw bootstrap, user rescroll between redraws, ws-mangled restore, wp mangled independently, the known false-negative case, and viewport-start off-by-one. Benchmarks (200 iterations, ~500-line scrollback): - auto-follow: 0.07 ms/redraw (matches baseline) - scrolled-up: 0.16 ms/redraw (was 0.08 ms; well under frame budget) Fixes #115
1 parent c5b38d5 commit 2efecf2

3 files changed

Lines changed: 1064 additions & 103 deletions

File tree

ghostel-debug.el

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -119,43 +119,72 @@ _PROC is ignored."
119119
(format-time-string "%T.%3N")
120120
key-name mods utf8)))))
121121

122+
(defun ghostel-debug--snapshot (buffer)
123+
"Return a plist of redraw-relevant state for BUFFER, or nil.
124+
Captures DEC 2026, force flag, buffer size, trailing-byte flag,
125+
point, `ghostel--term-rows', `ghostel--last-anchor-position',
126+
computed viewport-start, and per-window ws/we/wp/body-height."
127+
(when (buffer-live-p buffer)
128+
(with-current-buffer buffer
129+
(let* ((pm (point-max))
130+
(cb (and (> pm 1) (char-before pm)))
131+
(wins (get-buffer-window-list buffer nil t)))
132+
(list :sync (and ghostel--term
133+
(ghostel--mode-enabled ghostel--term 2026))
134+
:force ghostel--force-next-redraw
135+
:snap ghostel--snap-requested
136+
:buf-size (buffer-size)
137+
:trailing-nl (eq cb ?\n)
138+
:point (point)
139+
:term-rows ghostel--term-rows
140+
:anchor-pos ghostel--last-anchor-position
141+
:vs (ghostel--viewport-start)
142+
:wins (mapcar (lambda (w)
143+
(list :w w
144+
:ws (window-start w)
145+
:we (window-end w t)
146+
:wp (window-point w)
147+
:body (window-body-height w)))
148+
wins))))))
149+
150+
(defun ghostel-debug--fmt-wins (wins)
151+
"Format per-window entries WINS for the redraw log line."
152+
(mapconcat
153+
(lambda (w) (format "ws=%d we=%d wp=%d body=%d"
154+
(plist-get w :ws) (plist-get w :we)
155+
(plist-get w :wp) (plist-get w :body)))
156+
wins " | "))
157+
122158
(defun ghostel-debug--log-redraw (orig-fn buffer)
123159
"Log redraw decisions: skip vs execute, DEC 2026 state, timing.
124160
ORIG-FN is `ghostel--delayed-redraw', BUFFER is the target buffer."
125161
(when ghostel-debug--log-buffer
126-
(let (sync force win-start pt buf-size)
127-
(when (buffer-live-p buffer)
128-
(with-current-buffer buffer
129-
(setq sync (and ghostel--term
130-
(ghostel--mode-enabled ghostel--term 2026)))
131-
(setq force ghostel--force-next-redraw)
132-
(setq buf-size (buffer-size))
133-
(setq pt (point))
134-
(let ((win (get-buffer-window buffer)))
135-
(when win
136-
(setq win-start (window-start win))))))
137-
(let ((t0 (current-time)))
138-
(funcall orig-fn buffer)
139-
(let ((elapsed (* 1000 (float-time (time-subtract (current-time) t0))))
140-
pt-after win-start-after buf-size-after)
141-
(when (buffer-live-p buffer)
142-
(with-current-buffer buffer
143-
(setq pt-after (point))
144-
(setq buf-size-after (buffer-size))
145-
(let ((win (get-buffer-window buffer)))
146-
(when win
147-
(setq win-start-after (window-start win))))))
148-
(with-current-buffer ghostel-debug--log-buffer
149-
(goto-char (point-max))
150-
(if (and sync (not force))
151-
(insert (format "[%s] REDRAW: SKIPPED (DEC2026 active, force=nil)\n"
152-
(format-time-string "%T.%3N")))
153-
(insert (format "[%s] REDRAW: %.1fms force=%s dec2026=%s buf=%d%d pt=%d%d wstart=%s%s\n"
154-
(format-time-string "%T.%3N")
155-
elapsed force sync
156-
buf-size buf-size-after
157-
pt pt-after
158-
win-start win-start-after)))))))))
162+
(let ((before (ghostel-debug--snapshot buffer))
163+
(t0 (current-time)))
164+
(funcall orig-fn buffer)
165+
(let* ((elapsed (* 1000 (float-time (time-subtract (current-time) t0))))
166+
(after (ghostel-debug--snapshot buffer)))
167+
(with-current-buffer ghostel-debug--log-buffer
168+
(goto-char (point-max))
169+
(if (and (plist-get before :sync) (not (plist-get before :force)))
170+
(insert (format "[%s] REDRAW: SKIPPED (DEC2026 active, force=nil)\n"
171+
(format-time-string "%T.%3N")))
172+
(insert (format "[%s] REDRAW: %.1fms force=%s%s snap=%s%s dec2026=%s buf=%d%d trailNL=%s%s pt=%d%d rows=%s vs=%s%s anchor=%s%s\n"
173+
(format-time-string "%T.%3N")
174+
elapsed
175+
(plist-get before :force) (plist-get after :force)
176+
(plist-get before :snap) (plist-get after :snap)
177+
(plist-get before :sync)
178+
(plist-get before :buf-size) (plist-get after :buf-size)
179+
(plist-get before :trailing-nl) (plist-get after :trailing-nl)
180+
(plist-get before :point) (plist-get after :point)
181+
(plist-get after :term-rows)
182+
(plist-get before :vs) (plist-get after :vs)
183+
(plist-get before :anchor-pos) (plist-get after :anchor-pos)))
184+
(insert (format " wins-before: %s\n"
185+
(ghostel-debug--fmt-wins (plist-get before :wins))))
186+
(insert (format " wins-after: %s\n"
187+
(ghostel-debug--fmt-wins (plist-get after :wins))))))))))
159188

160189
(defun ghostel-debug--log-resize (orig-fn process windows)
161190
"Log resize events with old/new dimensions and timing.

0 commit comments

Comments
 (0)