Skip to content

Commit aa4912d

Browse files
committed
Keep viewport anchored across resize when Emacs drifts window-start
In TUIs whose cursor sits above the viewport bottom, opening the minibuffer shrinks the ghostel window and Emacs's `keep-point-visible' moves `window-start' forward below the anchor so the cursor stays on screen. The resulting `ws < anchor' looked identical to a real user scroll, so the resize-triggered force redraw captured a blank-row key, found it at `point-min', and jumped `window-start' to 1. During a resize-triggered redraw, treat a window absent from `ghostel--scroll-positions' as still anchored: the prior redraw left it auto-following the viewport, so any drift below the anchor must be Emacs redisplay, not a user scroll. Output-driven redraws, snap, clear-scrollback, and copy-exit all have their own anchoring paths and are unaffected. Closes #127.
1 parent 28f5071 commit aa4912d

2 files changed

Lines changed: 114 additions & 3 deletions

File tree

ghostel.el

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,16 @@ Updated whenever the terminal is created or resized.")
738738
(defvar-local ghostel--force-next-redraw nil
739739
"When non-nil, redraw regardless of synchronized output mode.")
740740

741+
(defvar ghostel--redraw-resize-active nil
742+
"Dynamically bound to t inside a resize-triggered `ghostel--delayed-redraw'.
743+
Read by `ghostel--window-anchored-p' so the redraw keeps auto-following
744+
windows anchored even when Emacs redisplay drifted `window-start' below
745+
the anchor between redraws (e.g. via `keep-point-visible' when the
746+
minibuffer shrinks the window). Not set for output-driven redraws,
747+
clear-scrollback, copy-exit, or snap-to-input — those either reset
748+
`ghostel--scroll-positions' or set `ghostel--snap-requested', both of
749+
which already produce the intended anchoring.")
750+
741751
(defvar-local ghostel--snap-requested nil
742752
"When non-nil, the next redraw should anchor `window-start' to the viewport.
743753
Set by `ghostel--snap-to-input' on user-initiated input (typing, paste,
@@ -2642,11 +2652,18 @@ position COL columns into the first matched line."
26422652
"Non-nil if WIN is auto-following the viewport.
26432653
A window counts as anchored when `ghostel--snap-requested' is set
26442654
\(the user just typed), when no anchor has been recorded yet (first
2645-
redraw), or when its `window-start' is at or past the prior anchor."
2655+
redraw), or when its `window-start' is at or past the prior anchor.
2656+
During a resize-triggered redraw, a window absent from
2657+
`ghostel--scroll-positions' also counts as anchored: the prior redraw
2658+
left it following the viewport, so a drifted `window-start' below the
2659+
anchor is Emacs redisplay (e.g. `keep-point-visible' when the
2660+
minibuffer shrinks the window), not a user scroll."
26462661
(let ((anchor ghostel--last-anchor-position))
26472662
(or ghostel--snap-requested
26482663
(null anchor)
2649-
(>= (window-start win) anchor))))
2664+
(>= (window-start win) anchor)
2665+
(and ghostel--redraw-resize-active
2666+
(not (assq win ghostel--scroll-positions))))))
26502667

26512668
(defun ghostel--capture-window-state (win)
26522669
"Return (WIN WS-KEY WP-KEY WP-COL) for WIN.
@@ -2806,7 +2823,12 @@ PROCESS is the shell process, WINDOWS is the list of windows."
28062823
(when ghostel--redraw-timer
28072824
(cancel-timer ghostel--redraw-timer)
28082825
(setq ghostel--redraw-timer nil))
2809-
(ghostel--delayed-redraw buffer))))
2826+
;; `ghostel--redraw-resize-active' lets `ghostel--window-anchored-p'
2827+
;; treat Emacs-induced `window-start' drift (from `keep-point-visible'
2828+
;; when a minibuffer-triggered resize shrinks the body) as drift,
2829+
;; not as a user scroll.
2830+
(let ((ghostel--redraw-resize-active t))
2831+
(ghostel--delayed-redraw buffer)))))
28102832
;; Return size — Emacs calls set-process-window-size (SIGWINCH)
28112833
;; after this function returns, matching eat/vterm timing.
28122834
size))

test/ghostel-test.el

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3091,6 +3091,15 @@ viewport. After the fix, the scrolled-up view is preserved."
30913091
(set-window-start (selected-window) (point-min) t)
30923092
(goto-char (point-min))
30933093
(set-window-point (selected-window) (point-min))
3094+
;; Real-world flow: some PTY output arrives between the
3095+
;; wheel-up and `M-x', so an output-driven redraw captures
3096+
;; the scrolled window into `ghostel--scroll-positions'
3097+
;; before the resize fires. Without this intermediate
3098+
;; capture the resize redraw's drift heuristic would
3099+
;; (correctly, by that heuristic) classify this window as
3100+
;; drifted-but-anchored and snap it back.
3101+
(ghostel--delayed-redraw buf)
3102+
(should (assq (selected-window) ghostel--scroll-positions))
30943103
(let ((ws-before (window-start (selected-window)))
30953104
(wp-before (window-point (selected-window))))
30963105

@@ -3125,6 +3134,86 @@ viewport. After the fix, the scrolled-up view is preserved."
31253134
(set-window-buffer (selected-window) orig-buf))
31263135
(kill-buffer buf))))
31273136

3137+
(ert-deftest ghostel-test-redraw-resize-preserves-anchor-when-emacs-drifts-ws ()
3138+
"Resize keeps the window anchored when Emacs drifted `window-start' below it.
3139+
Regression test for issue #127: in TUIs whose cursor sits above the
3140+
viewport bottom, opening the minibuffer shrinks the window body and
3141+
Emacs's `keep-point-visible' moves `window-start' forward so the TUI
3142+
cursor stays on screen. The resulting `ws < anchor' looked identical
3143+
to a real user scroll, so the force redraw captured a blank-row key,
3144+
found it at `point-min', and jumped `window-start' to 1.
3145+
3146+
With the fix, a force redraw classifies a window as anchored when it
3147+
wasn't recorded in `ghostel--scroll-positions' at the prior redraw —
3148+
so an Emacs-driven drift is treated as drift, not a scroll."
3149+
(let ((buf (generate-new-buffer " *ghostel-test-resize-anchor-drift*"))
3150+
(orig-buf (window-buffer (selected-window))))
3151+
(unwind-protect
3152+
(with-current-buffer buf
3153+
(ghostel-mode)
3154+
(let* ((term (ghostel--new 10 40 200))
3155+
(ghostel--term term)
3156+
(ghostel--term-rows 10)
3157+
(inhibit-read-only t))
3158+
;; Write enough blank-terminated lines that a drifted
3159+
;; ws-key would ambiguously match near `point-min'.
3160+
(dotimes (i 30)
3161+
(ghostel--write-input term (format "row-%02d\r\n" i)))
3162+
(ghostel--redraw term t)
3163+
(set-window-buffer (selected-window) buf)
3164+
;; Steady-state auto-follow; prior redraw seeds the anchor.
3165+
(goto-char (point-max))
3166+
(set-window-point (selected-window) (point-max))
3167+
(let ((vp (save-excursion
3168+
(goto-char (point-max))
3169+
(forward-line -9)
3170+
(line-beginning-position))))
3171+
(set-window-start (selected-window) vp t))
3172+
(setq ghostel--force-next-redraw t)
3173+
(ghostel--delayed-redraw buf)
3174+
(should ghostel--last-anchor-position)
3175+
(should-not ghostel--scroll-positions)
3176+
3177+
;; Simulate Emacs drift: `keep-point-visible' on a
3178+
;; minibuffer-triggered resize slides `window-start' a
3179+
;; couple rows below the anchor. Point stays in the live
3180+
;; viewport (TUI cursor on a row above the bottom).
3181+
(let ((drifted-ws (save-excursion
3182+
(goto-char ghostel--last-anchor-position)
3183+
(forward-line -2)
3184+
(line-beginning-position))))
3185+
(should (< drifted-ws ghostel--last-anchor-position))
3186+
(set-window-start (selected-window) drifted-ws t))
3187+
;; Window is NOT in `ghostel--scroll-positions' — it was
3188+
;; auto-following, not user-scrolled.
3189+
(should-not ghostel--scroll-positions)
3190+
3191+
;; Resize path (same harness as the scrolled-view test).
3192+
(cl-letf (((default-value 'window-adjust-process-window-size-function)
3193+
(lambda (&rest _) (cons 40 6)))
3194+
((symbol-function 'set-process-window-size) #'ignore))
3195+
(setq ghostel--process
3196+
(make-pipe-process :name "ghostel-test-fake"
3197+
:buffer buf
3198+
:noquery t
3199+
:filter #'ignore
3200+
:sentinel #'ignore))
3201+
(unwind-protect
3202+
(ghostel--window-adjust-process-window-size
3203+
ghostel--process
3204+
(list (selected-window)))
3205+
(delete-process ghostel--process)
3206+
(setq ghostel--process nil)))
3207+
3208+
;; Window must be re-anchored to the live viewport, NOT
3209+
;; yanked to `point-min'.
3210+
(should (= (ghostel--viewport-start)
3211+
(window-start (selected-window))))
3212+
(should (> (window-start (selected-window)) 1))))
3213+
(when (buffer-live-p orig-buf)
3214+
(set-window-buffer (selected-window) orig-buf))
3215+
(kill-buffer buf))))
3216+
31283217
(ert-deftest ghostel-test-viewport-start-skips-trailing-newline ()
31293218
"`ghostel--viewport-start' must not be off-by-one on a trailing \\n.
31303219
Partial redraws can leave the buffer ending with \\n (e.g. after

0 commit comments

Comments
 (0)