From 3bcb7ed7e8513eb4c2b10666e944fc4943531ec0 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Sun, 10 May 2026 00:55:20 +0200 Subject: [PATCH] Fix evil motion crossing into stale whitespace cells zsh's zle and prompt_toolkit redraw inline deletions by overwriting with spaces rather than emitting CSI K, leaving the Emacs buffer with trailing whitespace cells past the live end of input. evil's word motion treated those cells as content and walked off the input row - so 'word word wordbbcww' jumped to the next buffer line, and the following 'dw' sent down-arrows through the PTY, triggering zsh history navigation that scrubbed the input. Clamp 'evil-forward-word-begin' / '-word-end' / '-WORD-begin' / '-WORD-end', 'evil-end-of-line', 'evil-end-of-visual-line', and 'evil-last-non-blank' via a 'save-restriction' narrow whose upper bound is the terminal cursor's buffer position. When point is past the cursor (scrollback) the clamp is skipped so it can't cut point out of the visible region. Also fall back to readline C-a from 'evil-beginning-of-line' / 'evil-first-non-blank' when the cursor's row carries no 'ghostel-prompt' property - so '0' / '^' lands at input start even when the shell isn't sourcing the OSC 133 integration. '--sync-point-on-next-redraw' carries point to the new cursor. Add 'evil-ghostel-send-and-follow' so user-defined normal-state passthrough bindings can opt into the same redraw-time point sync; the redraw advice deliberately preserves point in normal state to keep column-only motions ('^', '$', '0') from being undone by incidental redraws, which is the right default but blocks 'send key, follow with point' use cases. Fixes #246 --- extensions/evil-ghostel/evil-ghostel.el | 137 ++++++++++++-- test/evil-ghostel-test.el | 229 +++++++++++++++++++++++- 2 files changed, 344 insertions(+), 22 deletions(-) diff --git a/extensions/evil-ghostel/evil-ghostel.el b/extensions/evil-ghostel/evil-ghostel.el index ef9da50..00e3d41 100644 --- a/extensions/evil-ghostel/evil-ghostel.el +++ b/extensions/evil-ghostel/evil-ghostel.el @@ -145,6 +145,41 @@ helper is unsafe there — use `evil-ghostel--last-cursor-line' instead." (when cursor-line (= (- (line-number-at-pos (point) t) 1) cursor-line)))) +(defun evil-ghostel--cursor-buffer-position () + "Return the buffer position at the terminal cursor's row+col, or nil. +Translates `ghostel--cursor-pos' through the scrollback offset to a +buffer line, then walks to the cursor's column. Used to clamp +forward motion at the end of input — anything past this position on +the cursor's row is stale whitespace from a redraw that didn't emit +`CSI K' (zsh's zle, prompt_toolkit at certain widths)." + (let ((line (evil-ghostel--cursor-buffer-line)) + (cur ghostel--cursor-pos)) + (when (and line cur) + (save-excursion + (goto-char (point-min)) + (forward-line line) + (move-to-column (car cur)) + (point))))) + +(defun evil-ghostel--cursor-row-has-prompt-prop-p () + "Non-nil when any character on the cursor's row carries `ghostel-prompt'. +Used by the `0'/`^' fallback to decide whether the existing +property-scan path can locate the input start, or whether to ask +readline directly via \\`C-a'." + (let ((line (evil-ghostel--cursor-buffer-line))) + (when line + (save-excursion + (goto-char (point-min)) + (forward-line line) + (let ((bol (line-beginning-position)) + (eol (line-end-position)) + (found nil)) + (while (and (< bol eol) (not found)) + (when (get-text-property bol 'ghostel-prompt) + (setq found t)) + (setq bol (1+ bol))) + found))))) + (defvar-local evil-ghostel--last-cursor-line nil "Buffer line where the previous redraw placed the terminal cursor. Used by `evil-ghostel--around-redraw' to recognize the case where the @@ -348,24 +383,33 @@ cursor." ;; Advice for beginning-of-line motions (defun evil-ghostel--around-beginning-of-line (orig-fn &rest args) - "Route `0' / `^' to `ghostel-beginning-of-input-or-line' on prompt rows. + "Route `0' / `^' to the input start, falling back through layers. ORIG-FN is the advised motion called with ARGS. In a shell or REPL, the literal column 0 lands point on top of the -prompt (`$ ', `>>> ') — almost never what the user wants. When -point is on a row that carries the `ghostel-prompt' text property -or the line-mode input marker, jump to the start of the editable -input instead so `0' / `^' followed by `i' lands typing at the -expected place, and `d0' / `c0' don't try to delete the prompt -characters. - -Falls through to ORIG-FN when ghostel isn't active or the row has -no prompt to skip — preserving standard motion semantics in -scrollback, output, and non-prompt rows." - (if (or (evil-ghostel--active-p) - (evil-ghostel--line-mode-active-p)) - (ghostel-beginning-of-input-or-line) - (apply orig-fn args))) +prompt (`$ ', `>>> ') — almost never what the user wants. Layers, +in order of preference: + +1. Line mode: `ghostel--line-input-start' marker. +2. Semi-char with `ghostel-prompt' property on the row: walk past it. +3. Semi-char on the cursor's row, no property (no OSC 133 sourced): + send \\`C-a' to readline so it moves the cursor to input start. + `evil-ghostel--sync-point-on-next-redraw' lands point there once + the echo is processed. +4. Otherwise (scrollback, no prompt to skip): default `move-beginning- + of-line', preserving standard motion semantics." + (cond + ((evil-ghostel--line-mode-active-p) + (ghostel-beginning-of-input-or-line)) + ((and (evil-ghostel--active-p) + (evil-ghostel--point-on-cursor-line-p) + (not (evil-ghostel--cursor-row-has-prompt-prop-p))) + (ghostel--send-encoded "a" "ctrl") + (evil-ghostel--invalidate-shadow) + (setq evil-ghostel--sync-point-on-next-redraw t)) + ((evil-ghostel--active-p) + (ghostel-beginning-of-input-or-line)) + (t (apply orig-fn args)))) ;; Advice for evil insert-line / append-line @@ -418,6 +462,64 @@ cursor to point's row, then send Ctrl-e in semi-char; jump to (t (apply orig-fn args)))) +;; Motion clamp: stop forward motion at the terminal cursor + +(defconst evil-ghostel--clamped-forward-motions + '(evil-forward-word-begin + evil-forward-word-end + evil-forward-WORD-begin + evil-forward-WORD-end + evil-end-of-line + evil-end-of-visual-line + evil-last-non-blank) + "Forward/end-of-line motions that must not cross the terminal cursor. +zsh's zle and prompt_toolkit redraw deletions by overwriting with +spaces rather than emitting `CSI K', leaving stale whitespace cells +in the Emacs buffer past the live end of input. Without clamping, +`w' / `e' / `$' walk into that region and subsequent operators +target the wrong text — see issue #246.") + +(defun evil-ghostel--around-clamped-forward-motion (orig-fn &rest args) + "Stop forward motion at the terminal cursor when on the input row. +ORIG-FN is the advised motion called with ARGS. When semi-char is +active and point is at or before the cursor's buffer position, +narrow the buffer's upper bound to the cursor so motion cannot +cross into stale whitespace cells. When point is past the cursor +\(scrollback) or off the cursor's row entirely, motion runs +unrestricted." + (if (evil-ghostel--active-p) + (let ((cursor-pos (evil-ghostel--cursor-buffer-position))) + (if (and cursor-pos (<= (point) cursor-pos)) + (save-restriction + (narrow-to-region (point-min) cursor-pos) + (apply orig-fn args)) + (apply orig-fn args))) + (apply orig-fn args))) + + +;; Public helpers + +(defun evil-ghostel-send-and-follow (key &optional modifier) + "Send KEY (with MODIFIER) to the terminal and let point follow. +Like a plain `ghostel--send-encoded', but also requests that the +next redraw move point to the new terminal cursor position +\(rather than restoring the saved point as the redraw advice does +in normal state by default). Use to wrap user keybindings that +should drive both the readline cursor and Emacs point — e.g. to +make Home in normal state behave like readline \\`C-a': + + (define-key evil-normal-state-map (kbd \"\") + (lambda () (interactive) + (evil-ghostel-send-and-follow \"a\" \"ctrl\"))) + +No-op outside semi-char (line mode owns its own input model; +copy/emacs/char modes don't expect terminal-driven motion)." + (when (evil-ghostel--active-p) + (ghostel--send-encoded key (or modifier "")) + (evil-ghostel--invalidate-shadow) + (setq evil-ghostel--sync-point-on-next-redraw t))) + + ;; Editing primitives (defun evil-ghostel--meaningful-length (text) @@ -764,6 +866,9 @@ state transitions." (advice-add 'evil-paste-before :around #'evil-ghostel--around-paste-before) (advice-add 'evil-undo :around #'evil-ghostel--around-undo) (advice-add 'evil-redo :around #'evil-ghostel--around-redo) + (dolist (motion evil-ghostel--clamped-forward-motions) + (advice-add motion :around + #'evil-ghostel--around-clamped-forward-motion)) (advice-add 'ghostel--redraw :around #'evil-ghostel--around-redraw) (advice-add 'ghostel--set-cursor-style :around #'evil-ghostel--override-cursor-style) @@ -785,6 +890,8 @@ state transitions." (advice-remove 'evil-paste-before #'evil-ghostel--around-paste-before) (advice-remove 'evil-undo #'evil-ghostel--around-undo) (advice-remove 'evil-redo #'evil-ghostel--around-redo) + (dolist (motion evil-ghostel--clamped-forward-motions) + (advice-remove motion #'evil-ghostel--around-clamped-forward-motion)) (advice-remove 'ghostel--redraw #'evil-ghostel--around-redraw) (advice-remove 'ghostel--set-cursor-style #'evil-ghostel--override-cursor-style))) diff --git a/test/evil-ghostel-test.el b/test/evil-ghostel-test.el index af0c639..b6aba39 100644 --- a/test/evil-ghostel-test.el +++ b/test/evil-ghostel-test.el @@ -1322,7 +1322,10 @@ inserts at the prompt position rather than at the input start." ;; Mark the prompt prefix so ghostel-beginning-of-input-or-line ;; treats it as a prompt row. (put-text-property 1 3 'ghostel-prompt t) - (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil))) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ;; Cursor on the same buffer row (row 0) so the prop-scan + ;; branch is selected over the C-a fallback. + (ghostel--cursor-pos '(9 . 0))) (evil-normal-state) (goto-char (point-max)) (evil-beginning-of-line) @@ -1333,18 +1336,42 @@ inserts at the prompt position rather than at the input start." (should (= 2 (current-column)))))) (ert-deftest evil-ghostel-test-beginning-of-line-falls-through-no-prompt () - "On rows without a prompt property `0' / `^' keep their default -column-0 / first-non-blank behaviour — scrollback navigation must -not be hijacked." + "Off the cursor row (true scrollback) `0' / `^' keep default +column-0 behaviour — scrollback navigation must not be hijacked +into sending `C-a' to readline." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert " output line") ; no ghostel-prompt property anywhere - (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil))) + (insert "scrollback line\n$ ") ; cursor on row 1, point on row 0 + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ;; Cursor on row 1 (the prompt row); point will be on row 0. + (ghostel--cursor-pos '(2 . 1)) + ((symbol-function 'ghostel--send-encoded) + (lambda (&rest _) (error "Should not send to PTY off cursor row")))) (evil-normal-state) - (goto-char (point-max)) + (goto-char (point-min)) + (forward-char 5) ; somewhere on the scrollback row (evil-beginning-of-line) (should (= 0 (current-column)))))) +(ert-deftest evil-ghostel-test-beginning-of-line-no-prop-sends-ctrl-a () + "On the cursor's row with no `ghostel-prompt' prop, `0' / `^' +sends `C-a' to readline so it can supply the input start without +needing OSC 133. Verifies the fallback path used when noctuid's +shell isn't sourcing the integration (issue #246)." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (insert "$ command") ; no ghostel-prompt property anywhere + (let ((sent nil)) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(9 . 0)) + ((symbol-function 'ghostel--send-encoded) + (lambda (key mod) (setq sent (cons key mod))))) + (evil-normal-state) + (goto-char (point-max)) + (evil-beginning-of-line) + (should (equal sent (cons "a" "ctrl"))) + (should evil-ghostel--sync-point-on-next-redraw))))) + ;; ----------------------------------------------------------------------- ;; Test: shadow cursor (queued-key model) ;; ----------------------------------------------------------------------- @@ -1508,6 +1535,185 @@ sticks." (should (= 0 (current-column))) (should (= 1 (line-number-at-pos))))) +;; ----------------------------------------------------------------------- +;; Test: motion clamp at terminal cursor (issue #246) +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-clamp-forward-word-stops-at-cursor () + "`w' on the cursor's row stops at the terminal cursor, not at +trailing-whitespace cells left over from a non-CSI-K redraw. +Mirrors zsh's zle behaviour: after deleting a word inline, zsh +overwrites the deleted columns with spaces — those spaces end up +in the Emacs buffer past the live end of input." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + ;; Buffer = `$ word word ' (5 stale spaces past col 11). + ;; Terminal cursor sits at col 11 — the live end of input. + (insert "$ word word ") + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(11 . 0))) + (evil-normal-state) + (goto-char (point-min)) + (forward-char 7) ; col 7 = start of second 'word' + (evil-forward-word-begin 1) + ;; Without the clamp, evil walks past the trailing spaces and + ;; jumps to the next buffer line — landing point at line 2. + ;; With the clamp the narrow's upper bound is the cursor, so + ;; motion stops at end-of-narrow (the cursor itself). + (should (= 1 (line-number-at-pos))) + (should (<= (current-column) 11))))) + +(ert-deftest evil-ghostel-test-clamp-end-of-line-stops-at-cursor () + "`$' lands at the terminal cursor's column, not on a trailing space." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (insert "$ word ") ; 5 stale spaces past col 6 + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(6 . 0))) + (evil-normal-state) + (goto-char (point-min)) + (forward-char 2) + (evil-end-of-line) + ;; Clamped: lands at col 5 (last char inside [point-min, cursor-pos)). + (should (= 5 (current-column)))))) + +(ert-deftest evil-ghostel-test-clamp-no-effect-when-point-past-cursor () + "When point is past the terminal cursor (scrollback / output +above the prompt), the clamp does nothing — narrowing would cut +point out of the visible region." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + ;; Two lines: scrollback `foo bar baz' on row 0, prompt on row 1. + ;; Cursor on row 1 col 2; point on row 0. + (insert "foo bar baz\n$ ") + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(2 . 1))) + (evil-normal-state) + (goto-char (point-min)) + (evil-forward-word-begin 1) + ;; Should land at col 4 (start of `bar') on row 0 — unrestricted. + (should (= 1 (line-number-at-pos))) + (should (= 4 (current-column)))))) + +(ert-deftest evil-ghostel-test-clamp-disabled-outside-semi-char () + "Clamp is a no-op outside semi-char so line/copy/emacs modes +keep their default motion semantics." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (setq-local ghostel--input-mode 'line) ; line mode -> not active-p + (insert "$ word word ") + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(11 . 0))) + (evil-normal-state) + (goto-char (point-min)) + (forward-char 7) + (evil-end-of-line) + ;; Unrestricted: lands at col 15 (last char of 16-col line; evil + ;; normal-state $ decrements one off `move-end-of-line'). Past + ;; the cursor at col 11 — proves the clamp is off. + (should (= 15 (current-column)))))) + +(ert-deftest evil-ghostel-test-clamp-survives-operator-pending-cw () + "`cw' goes through evil's operator-pending branch where +`evil-forward-word-begin' calls `bounds-of-thing-at-point' and +`forward-thing' instead of `evil-forward-beginning'. All three +must run cleanly under the clamp's `save-restriction'." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (insert "$ word word word") + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(16 . 0))) + (evil-normal-state) + (goto-char (point-min)) + (forward-char 7) ; col 7 = start of 2nd `word' + ;; Simulate operator-pending state: evil-state=operator and + ;; evil-this-operator=evil-change so the cw branch fires. + (let ((evil-state 'operator) + (evil-this-operator 'evil-change) + (orig (point))) + ;; What `cw' invokes for its end-of-range motion. + (evil-forward-word-begin 1) + ;; ce-style: lands past the end of current word — col 11. + (should (= 11 (current-column))) + (should (> (point) orig)))))) + +(ert-deftest evil-ghostel-test-cw-end-to-end-leaves-point-at-input-start () + "Drive the full `bbcw' path against a fresh `$ word word word' +prompt and assert point ends at col 7 (the start of the changed +word) in insert state — the user-visible expectation. + +The user-reported regression in issue #246 follow-up was that `cw' +\(after `bb') moved point to col 0 in zsh / col 2 in pi +instead of col 7. This test exercises the full advice chain with +mocked PTY effects." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (insert "$ word word word") + (let ((sent '())) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(16 . 0)) + ((symbol-function 'ghostel--send-encoded) + (lambda (k m) (push (cons k m) sent)))) + (evil-normal-state) + (goto-char (point-min)) + (forward-char 7) ; col 7 = start of 2nd `word' + (let ((beg (point)) + ;; Compute END as cw would, in operator-pending semantics. + (end (save-excursion + (let ((evil-state 'operator) + (evil-this-operator 'evil-change)) + (evil-forward-word-begin 1)) + (point)))) + (evil-change beg end 'exclusive nil nil)) + ;; Final state: insert mode, point at BEG (col 7). + (should (eq evil-state 'insert)) + (should (= 7 (current-column))) + (should (= 1 (line-number-at-pos))))))) + +(ert-deftest evil-ghostel-test-cw-then-dw-no-overdelete () + "End-to-end repro for issue #246 mechanism: after a `cw' that +leaves stale trailing whitespace (zsh-style redraw), the next +`w' must not jump to the next buffer line, and `dw' must target +text on the input row, not phantom-skipped content. + +Models the buffer state we observed in the live zsh repro: + cursor=(7 . 0) buffer=`$ word word ' point=8 col=7" + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (insert "$ word word ") ; post-cw zsh state + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(7 . 0))) + (evil-normal-state) + (goto-char (point-min)) + (forward-char 7) ; col 7 — same as terminal cursor + ;; `w' at col 7 with clamp must not jump off line 1. Evil + ;; signals end-of-buffer when the narrowed region has no next + ;; word; that's fine — it means motion didn't escape into stale + ;; cells. Point stays put. + (ignore-errors (evil-forward-word-begin 1)) + (should (= 1 (line-number-at-pos))) + (should (<= (current-column) 7))))) + +;; ----------------------------------------------------------------------- +;; Test: send-and-follow public helper (issue #246 part 2b) +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-send-and-follow-sets-sync-flag () + "`evil-ghostel-send-and-follow' sends the encoded key and arms +the next-redraw point sync, so user-defined normal-state +passthrough bindings can move both the readline cursor and Emacs +point." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (let ((sent nil)) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ((symbol-function 'ghostel--send-encoded) + (lambda (k m) (setq sent (cons k m))))) + (setq evil-ghostel--sync-point-on-next-redraw nil) + (evil-ghostel-send-and-follow "a" "ctrl") + (should (equal sent (cons "a" "ctrl"))) + (should evil-ghostel--sync-point-on-next-redraw))))) + ;; ----------------------------------------------------------------------- ;; Runner ;; ----------------------------------------------------------------------- @@ -1551,6 +1757,15 @@ sticks." evil-ghostel-test-escape-evil-fallback-when-lookup-nil evil-ghostel-test-beginning-of-line-skips-prompt evil-ghostel-test-beginning-of-line-falls-through-no-prompt + evil-ghostel-test-beginning-of-line-no-prop-sends-ctrl-a + evil-ghostel-test-clamp-forward-word-stops-at-cursor + evil-ghostel-test-clamp-end-of-line-stops-at-cursor + evil-ghostel-test-clamp-no-effect-when-point-past-cursor + evil-ghostel-test-clamp-disabled-outside-semi-char + evil-ghostel-test-clamp-survives-operator-pending-cw + evil-ghostel-test-cw-end-to-end-leaves-point-at-input-start + evil-ghostel-test-cw-then-dw-no-overdelete + evil-ghostel-test-send-and-follow-sets-sync-flag evil-ghostel-test-shadow-cursor-tracks-cursor-to-point evil-ghostel-test-shadow-cursor-tracks-delete-region evil-ghostel-test-delete-word-with-trailing-space