Skip to content

Commit 606ec4d

Browse files
Cianidosdakra
authored andcommitted
Preserve point and evil visual markers across redraws
`evil-ghostel--around-redraw' used to save and restore only `point', and only in non-insert/emacs states. Native `ghostel--redraw' rewrites the viewport region on every call, moving every marker in the buffer — `evil-visual-beginning' and `evil-visual-end' drift asymmetrically by insertion-type, so `v' in a buffer with a streaming TUI (Claude Code, watch, streaming logs) shows a multi-row selection the moment `v' is pressed: mark had drifted backwards and `evil-visual-end' ratcheted forward while the user was idle in the buffer. Extend the advice to save and restore: - `point' in non-terminal states (unchanged). - `evil-visual-beginning' and `evil-visual-end' in `visual' state. `mark' is preserved by `ghostel--redraw' itself (see PR #163 — the native-side fix lives in `src/render.zig', where all users benefit regardless of whether evil-ghostel is loaded). This PR layers on top of that — it can be merged after or before #163; if merged first, `mark' stays drifting until the native patch lands. Tests: three mock-based cases covering point preservation in normal, point follow-through in emacs, and visual-marker preservation in visual.
1 parent 4816ece commit 606ec4d

2 files changed

Lines changed: 116 additions & 25 deletions

File tree

evil-ghostel.el

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,24 +102,42 @@ placement math the native module performs in `src/render.zig'."
102102
((< dx 0) (dotimes (_ (abs dx)) (ghostel--send-encoded "left" "")))))))
103103

104104
;; ---------------------------------------------------------------------------
105-
;; Redraw: preserve point in normal state
105+
;; Redraw: preserve point and evil visual markers across the native call
106106
;; ---------------------------------------------------------------------------
107107

108108
(defun evil-ghostel--around-redraw (orig-fn term &optional full)
109-
"Preserve Emacs point during redraws in evil normal state.
109+
"Preserve point and evil visual markers across the native redraw call.
110+
Native `ghostel--redraw' in `src/render.zig' rewrites the viewport
111+
region, moving every marker in the buffer. `point' (non-terminal
112+
states) and the evil-specific visual range markers are restored here;
113+
`mark' is preserved by the native module itself and needs no handling
114+
at this layer.
115+
116+
- `point' in non-terminal states. In `insert' and `emacs' point
117+
intentionally follows the TUI cursor.
118+
- `evil-visual-beginning' and `evil-visual-end' in `visual' state.
119+
110120
ORIG-FN is the advised `ghostel--redraw' called with TERM and FULL.
111-
Without this, the ~30fps redraw timer would snap point back to
112-
the terminal cursor, undoing any evil `normal-mode' navigation.
113-
`emacs-state' is evil's vanilla-Emacs escape hatch; point should
114-
follow the terminal cursor there just like it does in insert-state,
115-
otherwise the cursor gets stuck wherever it was on state entry while
116-
the TUI keeps redrawing elsewhere."
121+
Skipped when the terminal is in alt-screen mode (1049); apps there
122+
own the screen and drive their own redraw cycle."
117123
(if (and evil-ghostel-mode
118-
(not (memq evil-state '(insert emacs)))
119124
(not (ghostel--mode-enabled term 1049)))
120-
(let ((saved-point (point)))
125+
(let* ((preserve-point (not (memq evil-state '(insert emacs))))
126+
(visual-p (eq evil-state 'visual))
127+
(saved-point (and preserve-point (point)))
128+
(saved-vb (and visual-p (bound-and-true-p evil-visual-beginning)
129+
(marker-position evil-visual-beginning)))
130+
(saved-ve (and visual-p (bound-and-true-p evil-visual-end)
131+
(marker-position evil-visual-end))))
121132
(funcall orig-fn term full)
122-
(goto-char (min saved-point (point-max))))
133+
(when preserve-point
134+
(goto-char (min saved-point (point-max))))
135+
(when visual-p
136+
(let ((pmax (point-max)))
137+
(when saved-vb
138+
(set-marker evil-visual-beginning (min saved-vb pmax)))
139+
(when saved-ve
140+
(set-marker evil-visual-end (min saved-ve pmax))))))
123141
(funcall orig-fn term full)))
124142

125143
;; ---------------------------------------------------------------------------

test/evil-ghostel-test.el

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,93 @@ Regression guard: the minor-mode body used to call
127127
(evil-ghostel-test--with-evil-buffer
128128
(should-not evil-move-cursor-back)))
129129

130+
;; -----------------------------------------------------------------------
131+
;; Test: around-redraw preserves point / mark / visual markers
132+
;; -----------------------------------------------------------------------
133+
134+
(defmacro evil-ghostel-test--simulating-redraw (&rest body)
135+
"Run BODY with `ghostel--redraw' replaced by a buffer-rewriter.
136+
The mock erases the buffer and reinserts the same text, which is what
137+
the native full-redraw path does at the Emacs level — every marker in
138+
the buffer snaps to `point-min' across the call."
139+
`(cl-letf (((symbol-function 'ghostel--redraw)
140+
(lambda (_term &optional _full)
141+
(let ((text (buffer-string)))
142+
(erase-buffer)
143+
(insert text))))
144+
((symbol-function 'ghostel--mode-enabled)
145+
(lambda (_term _mode) nil)))
146+
,@body))
147+
148+
(ert-deftest evil-ghostel-test-around-redraw-preserves-point-in-normal ()
149+
"Point is restored in non-terminal states after the native redraw call."
150+
(evil-ghostel-test--with-evil-buffer
151+
(insert "one\ntwo\nthree\nfour\nfive\n")
152+
(evil-normal-state)
153+
(goto-char (point-min))
154+
(search-forward "three")
155+
(let ((target (point)))
156+
(evil-ghostel-test--simulating-redraw
157+
(evil-ghostel--around-redraw (symbol-function 'ghostel--redraw) nil))
158+
(should (= target (point))))))
159+
160+
(ert-deftest evil-ghostel-test-around-redraw-lets-point-follow-in-emacs ()
161+
"Point is NOT preserved in `emacs'/`insert' — it follows the TUI cursor."
162+
(evil-ghostel-test--with-evil-buffer
163+
(insert "one\ntwo\nthree\nfour\nfive\n")
164+
(evil-emacs-state)
165+
(goto-char (point-min))
166+
(search-forward "three")
167+
(evil-ghostel-test--simulating-redraw
168+
;; Mock redraw places point at point-min (like eraseBuffer does).
169+
(evil-ghostel--around-redraw
170+
(lambda (_term &optional _full)
171+
(let ((text (buffer-string)))
172+
(erase-buffer)
173+
(insert text)
174+
(goto-char (point-min))))
175+
nil))
176+
(should (= (point-min) (point)))))
177+
178+
(ert-deftest evil-ghostel-test-around-redraw-preserves-visual-markers ()
179+
"`evil-visual-beginning'/`evil-visual-end' are restored in visual state."
180+
(evil-ghostel-test--with-evil-buffer
181+
(insert "one\ntwo\nthree\nfour\nfive\n")
182+
(goto-char (point-min))
183+
(search-forward "two")
184+
(let ((vb-target (point)))
185+
(search-forward "four")
186+
(let ((ve-target (point)))
187+
(setq-local evil-visual-beginning (copy-marker vb-target))
188+
(setq-local evil-visual-end (copy-marker ve-target t))
189+
(let ((evil-state 'visual))
190+
(evil-ghostel-test--simulating-redraw
191+
(evil-ghostel--around-redraw
192+
(symbol-function 'ghostel--redraw) nil)))
193+
(should (= vb-target (marker-position evil-visual-beginning)))
194+
(should (= ve-target (marker-position evil-visual-end)))))))
195+
196+
(ert-deftest evil-ghostel-test-around-redraw-bypassed-in-alt-screen ()
197+
"Advice is a passthrough when the terminal is in alt-screen mode (1049).
198+
Fullscreen TUIs own the screen and drive their own redraw cycle; the
199+
advice must not restore point or visual markers there."
200+
(evil-ghostel-test--with-evil-buffer
201+
(insert "one\ntwo\nthree\nfour\nfive\n")
202+
(evil-normal-state)
203+
(goto-char (point-min))
204+
(search-forward "three")
205+
(cl-letf (((symbol-function 'ghostel--redraw)
206+
(lambda (_term &optional _full)
207+
(let ((text (buffer-string)))
208+
(erase-buffer)
209+
(insert text)
210+
(goto-char (point-min)))))
211+
((symbol-function 'ghostel--mode-enabled)
212+
(lambda (_term mode) (= mode 1049))))
213+
(evil-ghostel--around-redraw (symbol-function 'ghostel--redraw) nil))
214+
;; Advice bypassed → the mock's point placement (point-min) wins.
215+
(should (= (point-min) (point)))))
216+
130217
;; -----------------------------------------------------------------------
131218
;; Test: reset-cursor-point
132219
;; -----------------------------------------------------------------------
@@ -382,20 +469,6 @@ redrawing elsewhere."
382469
(ghostel--set-cursor-style 0 t)
383470
(should evil-called)))))
384471

385-
;; -----------------------------------------------------------------------
386-
;; Test: normal-state-entry hook
387-
;; -----------------------------------------------------------------------
388-
389-
(ert-deftest evil-ghostel-test-normal-entry-snaps-point ()
390-
"Test that entering normal state snaps point to terminal cursor."
391-
(evil-ghostel-test--with-buffer 5 40 "hello world"
392-
(evil-insert-state)
393-
;; Move point away
394-
(goto-char (point-min))
395-
;; Enter normal state — should snap to terminal cursor
396-
(evil-normal-state)
397-
(should (= 11 (current-column)))))
398-
399472
;; -----------------------------------------------------------------------
400473
;; Test: delete-region primitive
401474
;; -----------------------------------------------------------------------

0 commit comments

Comments
 (0)