diff --git a/extensions/evil-ghostel/evil-ghostel.el b/extensions/evil-ghostel/evil-ghostel.el index 652d1db4..849a71de 100644 --- a/extensions/evil-ghostel/evil-ghostel.el +++ b/extensions/evil-ghostel/evil-ghostel.el @@ -13,8 +13,16 @@ ;;; Commentary: ;; Provides evil-mode compatibility for the ghostel terminal emulator. -;; Synchronizes the terminal cursor with Emacs point during evil state -;; transitions so that normal-mode navigation works correctly. +;; Defines `evil-ghostel-*' commands (operators, motions, insert/append +;; variants) and binds them via `evil-ghostel-mode-map' for normal and +;; visual states. Each command clamps its range to the live input +;; region and drives the shell's readline via PTY arrow keys and +;; backspaces, so motion-overshoot (e.g. `cw' at end-of-input) cannot +;; over-delete past the cursor. +;; +;; Outside `semi-char' input mode the commands fall through to vanilla +;; `evil-*' so line/copy/emacs modes (which edit buffer text directly) +;; behave like ordinary evil buffers. ;; ;; Enable by adding to your init: ;; @@ -70,16 +78,37 @@ Sets the initial value of the buffer-local state. Use (const :tag "Always to terminal" terminal) (const :tag "Always to evil" evil))) +(defcustom evil-ghostel-right-prompt-gap 6 + "Minimum whitespace run that separates input from a right-aligned prompt. +`evil-ghostel--cursor-row-end-point' uses this when it cannot find an +OSC 133;B `ghostel-input' anchor on the cursor row. Walking backward +from EOL, a whitespace run of at least this many columns is treated +as the separator between user input (on the left) and a right-aligned +prompt or status indicator (on the right) — for example fish's +`fish_right_prompt' content like `main *'. Content right of the gap +is excluded from `$' / `y$' / clamped operator ranges so the right +prompt is never edited as if it were typed input. + +Set to a very large number (e.g. 999) to disable the heuristic. +Lower values catch tighter right-prompt gaps but risk false positives +on regular input that contains long runs of spaces (e.g. tabular +output piped to a column-aligned `read'). The default 6 matches +fish's typical padding while staying conservative on normal input." + :type 'integer) + ;; Apply the current value at load. Covers the case where the user set ;; the variable with plain `setq' before loading the package — in that ;; path `defcustom' preserves the value without invoking `:set'. (evil-set-initial-state 'ghostel-mode evil-ghostel-initial-state) -;; Guard predicate +;; Guard predicates (defun evil-ghostel--active-p () - "Return non-nil when evil-ghostel editing should intercept." + "Return non-nil when evil-ghostel PTY routing should intercept. +True in `semi-char' input mode and outside alt-screen — the only +combination where `evil-ghostel-*' commands send PTY keys instead +of falling through to vanilla `evil-*'." (and evil-ghostel-mode ghostel--term (not (ghostel--mode-enabled ghostel--term 1049)) @@ -153,67 +182,9 @@ redraw — only then does the renderer's cursor placement win across the redraw, so column-only navigation (`^', `$', `0', and the like) survives redraws that *don't* scroll the prompt.") -(defvar-local evil-ghostel--shadow-cursor nil - "Pending terminal cursor (COL . VIEWPORT-ROW), or nil to read live state. -Within a single advice call we may emit several key sequences -\(arrow-key sync, then backspaces, then another sync) before any -of them are echoed by the PTY. `ghostel--cursor-pos' reflects -the rendered state, which lags our queued keys, so a second -sync that reads it would compute deltas from a stale baseline and -over-correct. The shadow models where the cursor will land once -the queue drains; `evil-ghostel--cursor-to-point' reads it in -preference to the live value. - -Reset by `evil-ghostel--around-redraw' after the renderer has -processed the echo, and by operations whose cursor effect we -cannot model (Ctrl-a/e/u, paste).") - -(defun evil-ghostel--shadow-or-live () - "Return best-known terminal cursor (COL . VIEWPORT-ROW), or nil. -Shadow value if set, otherwise the rendered cursor from `ghostel--cursor-pos'." - (or evil-ghostel--shadow-cursor ghostel--cursor-pos)) - -(defun evil-ghostel--invalidate-shadow () - "Clear `evil-ghostel--shadow-cursor'. -Call after operations whose cursor effect we cannot model so the -next read falls back to the live libghostty position." - (setq evil-ghostel--shadow-cursor nil)) - -(defun evil-ghostel--cursor-to-point () - "Move the terminal cursor to Emacs point by sending arrow keys. -`ghostel--cursor-pos' holds the row within the viewport (the -last `ghostel--term-rows' lines), so the buffer line must be -converted to a viewport row by subtracting the scrollback offset — -otherwise dy is wrong by exactly the scrollback line count. - -Reads `evil-ghostel--shadow-cursor' in preference to the live -libghostty cursor (which lags any keys we have just sent), and -updates the shadow to point's position so a follow-up call within -the same operation sees the post-keys baseline rather than the -still-stale live value." - (when ghostel--term - (let* ((tpos (evil-ghostel--shadow-or-live)) - (tcol (car tpos)) - (trow (cdr tpos)) - (ecol (current-column)) - (erow (or (evil-ghostel--point-viewport-row) 0)) - (dy (- erow trow)) - (dx (- ecol tcol))) - (cond ((> dy 0) (dotimes (_ dy) (ghostel--send-encoded "down" ""))) - ((< dy 0) (dotimes (_ (abs dy)) (ghostel--send-encoded "up" "")))) - (cond ((> dx 0) (dotimes (_ dx) (ghostel--send-encoded "right" ""))) - ((< dx 0) (dotimes (_ (abs dx)) (ghostel--send-encoded "left" "")))) - (setq evil-ghostel--shadow-cursor (cons ecol erow))))) - ;; Redraw: preserve point and evil visual markers across the native call -(defvar-local evil-ghostel--sync-point-on-next-redraw nil - "When non-nil, the next `ghostel--redraw' moves point to the terminal cursor. -Set by operations that send PTY commands which will reposition the -terminal cursor (e.g. the same-row `dd' Ctrl-u path) where Emacs -point would otherwise be left at a now-stale position.") - (defun evil-ghostel--around-redraw (orig-fn term &optional full) "Preserve point and evil visual markers across the native redraw call. Native `ghostel--redraw' in `src/render.zig' rewrites the viewport @@ -237,9 +208,7 @@ Skipped when the terminal is in alt-screen mode (1049); apps there own the screen and drive their own redraw cycle." (if (and evil-ghostel-mode (not (ghostel--mode-enabled term 1049))) - (let* ((sync-flag evil-ghostel--sync-point-on-next-redraw) - (preserve-point (and (not sync-flag) - (not (memq evil-state '(insert emacs))))) + (let* ((preserve-point (not (memq evil-state '(insert emacs)))) (visual-p (eq evil-state 'visual)) (saved-point (and preserve-point (point))) ;; Pre-redraw, was the user parked on the cursor's @@ -263,9 +232,6 @@ own the screen and drive their own redraw cycle." (saved-ve (and visual-p (bound-and-true-p evil-visual-end) (marker-position evil-visual-end)))) (funcall orig-fn term full) - (when sync-flag - (setq evil-ghostel--sync-point-on-next-redraw nil) - (evil-ghostel--reset-cursor-point)) (let* ((post-cursor-line (evil-ghostel--cursor-buffer-line)) (prompt-moved (and was-on-prompt-line post-cursor-line @@ -281,12 +247,7 @@ own the screen and drive their own redraw cycle." ;; Record where the renderer placed the cursor so the next ;; redraw can detect whether the user is still at the ;; prompt line. - (setq evil-ghostel--last-cursor-line post-cursor-line)) - ;; The renderer's draw reflects all PTY output processed up - ;; to this point — any shadow cursor we maintained for queued - ;; keys is at best stale, at worst wrong. Reset so the next - ;; cursor read falls back to the live libghostty position. - (evil-ghostel--invalidate-shadow)) + (setq evil-ghostel--last-cursor-line post-cursor-line))) (funcall orig-fn term full))) @@ -305,328 +266,837 @@ In alt-screen mode, defer to the terminal's cursor style." ;; Evil state hooks -(defvar evil-ghostel--sync-inhibit nil - "When non-nil, skip arrow-key sync in the insert-state-entry hook. -Set by the I/A advice which send Home/End directly.") - (defun evil-ghostel--insert-state-entry () - "Sync terminal cursor to Emacs point when entering `emacs-state'. -Skipped when `evil-ghostel--sync-inhibit' is set (by I/A advice -which already sent Ctrl-a/Ctrl-e). Also skipped outside semi-char: -in line mode point and the terminal cursor are intentionally -decoupled (the user is editing buffer text, not driving the shell -cursor); in copy/Emacs/char modes the sync would either fight a -read-only buffer or be redundant. + "Sync terminal cursor to Emacs point on insert/emacs state entry. +Safety net for transitions that did not route through the +`evil-ghostel-*' commands (which already drive the shell cursor to +their target via `evil-ghostel-goto-input-position'). Skipped +outside semi-char: in line mode point and the terminal cursor are +intentionally decoupled (the user is editing buffer text, not +driving the shell cursor); in copy/Emacs/char modes the sync would +either fight a read-only buffer or be redundant. + When point is on a different row from the terminal cursor, snap back to the terminal cursor instead of sending up/down arrows which the shell would interpret as history navigation." - (when (derived-mode-p 'ghostel-mode) - (if evil-ghostel--sync-inhibit - (setq evil-ghostel--sync-inhibit nil) - (when (evil-ghostel--active-p) - (let* ((tpos ghostel--cursor-pos) - (trow (cdr tpos)) - ;; `tpos' is viewport-relative; convert point's buffer - ;; line to a viewport row before comparing — otherwise - ;; in any session with scrollback the rows compare as - ;; unequal even when point is on the cursor's row, and - ;; we drop into `reset-cursor-point' which snaps point - ;; back to the terminal cursor (silently undoing the - ;; user's `^', `$', `0' navigation). - (erow (or (evil-ghostel--point-viewport-row) 0))) - (if (= erow trow) - (evil-ghostel--cursor-to-point) - (evil-ghostel--reset-cursor-point))))))) + (when (and (derived-mode-p 'ghostel-mode) + (evil-ghostel--active-p)) + (let* ((tpos ghostel--cursor-pos) + (trow (cdr tpos)) + ;; `tpos' is viewport-relative; convert point's buffer + ;; line to a viewport row before comparing — otherwise + ;; in any session with scrollback the rows compare as + ;; unequal even when point is on the cursor's row, and + ;; we drop into `reset-cursor-point' which snaps point + ;; back to the terminal cursor (silently undoing the + ;; user's `^', `$', `0' navigation). + (erow (or (evil-ghostel--point-viewport-row) 0))) + (if (= erow trow) + (evil-ghostel-goto-input-position (point)) + (evil-ghostel--reset-cursor-point))))) (defun evil-ghostel--escape-stay () "Disable `evil-move-cursor-back' in ghostel buffers. -Moving the cursor back on ESC desynchronizes point from the terminal -cursor." +Moving the cursor back on ESC desynchronizes point from the terminal cursor." (setq-local evil-move-cursor-back nil)) + +;; PTY-driven input editing +;; +;; Drive the running shell's line editor (readline / zle / prompt_toolkit) +;; by sending arrow keys + backspace + bracketed paste through the PTY. +;; Assumes a cooperative line editor — in raw-mode TUIs (vim, less, htop) +;; the keys are interpreted by the inner program, not used for editing, +;; and these helpers will return nil or silently no-op. Only meaningful +;; in `semi-char' input mode. + +(defun evil-ghostel--input-end-via-property () + "Return position right after the first `ghostel-input' region on the row. +Returns the *first* region (not the rightmost) so a later region from +fish's right-prompt cells — which libghostty's per-cell semantic +heuristic also tags SEMANTIC_INPUT, despite no 133;B emission — is +ignored. The typed-input region is always the first one because the +prompt prefix cells (which precede it) carry `ghostel-prompt' rather +than `ghostel-input'. + +For bash / zsh with proper 133;B integration, typed input is a single +contiguous region anyway (cells between 133;B and 133;C all carry +SEMANTIC_INPUT, including any whitespace inside the input), so the +first-region answer equals the rightmost-cell answer. + +Returns nil when no `ghostel-input' cells are found on the row." + (when ghostel--cursor-char-pos + (save-excursion + (goto-char ghostel--cursor-char-pos) + (let* ((bol (line-beginning-position)) + (eol (line-end-position)) + (region-start (text-property-any bol eol 'ghostel-input t))) + (when region-start + (next-single-property-change region-start 'ghostel-input nil eol)))))) + +(defun evil-ghostel--input-end-via-gap () + "Return the position just after typed input on the cursor row, heuristically. +Strips trailing whitespace from EOL, then walks backward looking for +a whitespace run of `evil-ghostel-right-prompt-gap' or more columns; +when one is found, treats content right of the gap as a right-aligned +prompt and returns the position just before the gap. Without such a +gap, returns the position right after the last non-whitespace +character on the row. + +Used as the fallback when `evil-ghostel--input-end-via-property' +returns nil — most importantly for fish (whose right prompt is drawn +as ordinary cells without OSC 133;B markers)." + (when ghostel--cursor-char-pos + (save-excursion + (goto-char ghostel--cursor-char-pos) + (let* ((bol (line-beginning-position)) + (eol (line-end-position)) + (gap evil-ghostel-right-prompt-gap) + (scan eol) + (result nil)) + (goto-char eol) + (skip-chars-backward " \t" bol) + (setq scan (point)) + (while (and (> scan bol) (not result)) + (if (not (memq (char-before scan) '(?\s ?\t))) + (setq scan (1- scan)) + (let ((ws-end scan)) + (while (and (> scan bol) + (memq (char-before scan) '(?\s ?\t))) + (setq scan (1- scan))) + (when (>= (- ws-end scan) gap) + (setq result scan))))) + (or result + (save-excursion + (goto-char eol) + (skip-chars-backward " \t" bol) + (point))))))) + +(defun evil-ghostel--cursor-row-end-point () + "Return the position just after typed input on the cursor row. +Prefers `evil-ghostel--input-end-via-property' (the end of the first +contiguous `ghostel-input' region — the typed input). Falls back +to `evil-ghostel--input-end-via-gap' when no `ghostel-input' cells +are present on the row (shell session without OSC 133 integration). + +Returns nil when no cursor row is known. Used by `$', `y$', the +forward-motion clamps, and the operator range clamp to keep ranges +from extending into renderer-emitted padding or a right-aligned +prompt (fish `fish_right_prompt', zsh-autosuggest hint, RPROMPT)." + (or (evil-ghostel--input-end-via-property) + (evil-ghostel--input-end-via-gap))) + +(defun evil-ghostel-point-in-input-p (&optional pos) + "Return non-nil when POS (default `point') is in the editable input region. +POS must be on the cursor's row AND between `ghostel-input-start-point' +and `evil-ghostel--cursor-row-end-point' (inclusive). Modeled on +`vterm-cursor-in-command-buffer-p'. Returns nil when no terminal +cursor is available." + (when (ghostel-point-on-cursor-row-p pos) + (let ((p (or pos (point))) + (start (ghostel-input-start-point)) + (row-end (evil-ghostel--cursor-row-end-point))) + (and start row-end (>= p start) (<= p row-end))))) + +(defun evil-ghostel--input-start-from-prop () + "Return input start derived from the cursor row's `ghostel-prompt' prop, or nil. +Distinct from `ghostel-input-start-point' in that the property +fallback to `ghostel--cursor-char-pos' is omitted — callers that +need a *reliable* prompt-anchored boundary (e.g. clamping) get nil +when no OSC 133 prop is available rather than mistaking the live +cursor for the input's left edge." + (let ((cursor-pos ghostel--cursor-char-pos)) + (when cursor-pos + (let* ((row-start (save-excursion + (goto-char cursor-pos) + (line-beginning-position))) + (pos cursor-pos)) + (while (and (> pos row-start) + (not (get-text-property (1- pos) 'ghostel-prompt))) + (setq pos (1- pos))) + (and (> pos row-start) + (get-text-property (1- pos) 'ghostel-prompt) + pos))))) + +(defun evil-ghostel--clamp-to-input (region) + "Clamp REGION (a (BEG . END) cons) to the input region. +Returns a new cons. + +When both endpoints sit on the cursor's row, END is clamped to +`evil-ghostel--cursor-row-end-point' (past-end of typed input). BEG +is clamped to the OSC 133 prompt prefix when that's available; +without the prop, the start of input is unknown and BEG is left +alone (so operators in zsh / bash without shell integration don't +get their ranges collapsed to nothing). + +When the range starts on the cursor's row but END walks off it +\(forward-word overshoot at end-of-input, e.g. `dw' on the last +word), END is clamped to `evil-ghostel--cursor-row-end-point' — +backspaces can't reach into renderer-painted cells anyway, and +over-deleting would erase real input. + +Other off-row ranges (scrollback selections, multi-row TUI prompts +above the cursor) pass through unchanged." + (let* ((beg (car region)) + (end (cdr region)) + (start (evil-ghostel--input-start-from-prop)) + (row-end (evil-ghostel--cursor-row-end-point)) + (beg-on-row (ghostel-point-on-cursor-row-p beg)) + (end-on-row (ghostel-point-on-cursor-row-p end))) + (cond + ((and beg-on-row end-on-row row-end) + (cons (if start (max start (min row-end beg)) beg) + (max (or start beg) (min row-end end)))) + ((and beg-on-row (not end-on-row) row-end (> end row-end)) + (cons (if start (max start beg) beg) row-end)) + (t region)))) + +(defun evil-ghostel--meaningful-input-length (text) + "Length of TEXT, stripping per-line trailing whitespace in multi-line ranges. +Heuristic for TUIs that draw a fixed-width input box wider than +the user's typed text (e.g. prompt_toolkit-based REPLs that fill +each input row out to the box's right border). The trailing +spaces end up in the buffer because the terminal explicitly wrote +them, but they are not characters in the TUI's input model. -;; 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. -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. +Only applied when TEXT spans more than one buffer line. In a +single-line range trailing whitespace is treated as real user +input and counted, so single-word deletions don't leave a stray +character behind." + (if (string-match-p "\n" text) + (length (replace-regexp-in-string "[ \t]+\\(\n\\|\\'\\)" "\\1" text)) + (length text))) -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." +(defcustom evil-ghostel-sync-render-max-iterations 10 + "Maximum iterations of the drain loop in `evil-ghostel--sync-render'. +Each iteration waits up to 50 ms for output; the cap of 10 +bounds total wait at ~500 ms so a runaway shell can't hang the +caller (e.g. `cc' / `cw' invoking `delete-input-region')." + :type 'integer + :group 'evil-ghostel) + +(defun evil-ghostel--sync-render () + "Drain pending PTY output so cursor state reflects the latest echo. +Loops on `accept-process-output' (with `just-this-one' set so +other subprocesses are not advanced) until output stops arriving +or `evil-ghostel-sync-render-max-iterations' is reached; then, if +the filter deferred the redraw to its timer (the bulk-output +branch in `ghostel--filter' fires when output exceeds +`ghostel-immediate-redraw-threshold' or arrives outside +`ghostel-immediate-redraw-interval'), cancels the timer and +runs `ghostel--delayed-redraw' synchronously. + +The forced redraw is what updates `ghostel--cursor-pos' / +`ghostel--cursor-char-pos'; without it, callers reading those +right after a >256-byte echo (e.g. `delete-input-region' sending +100 backspaces, then `evil-ghostel-insert' computing arrow +deltas) see stale state. + +Used by `evil-ghostel-goto-input-position' and +`evil-ghostel-delete-input-region'." + (when (and ghostel--process (process-live-p ghostel--process)) + (let ((iter 0)) + (while (and (< iter evil-ghostel-sync-render-max-iterations) + (accept-process-output ghostel--process 0.05 nil t)) + (setq iter (1+ iter)))) + (when (or ghostel--pending-output ghostel--redraw-timer) + (when ghostel--redraw-timer + (cancel-timer ghostel--redraw-timer) + (setq ghostel--redraw-timer nil)) + (ghostel--delayed-redraw (current-buffer))))) + +(defun evil-ghostel-goto-input-position (pos) + "Move the terminal cursor and Emacs point to buffer position POS. +Returns t when the cursor reached POS, nil otherwise. + +Sends |dy| up/down + |dx| left/right arrow keys to drive the +shell's readline (or equivalent) cursor toward POS. POS must be +on, above, or below the terminal cursor's row; horizontal moves +beyond the input's edges are clamped by the shell. On success, +drains the echo synchronously and snaps Emacs `point' to the +terminal cursor's new buffer position, so the cursor and point +agree after the call (analogous to vterm's `vterm-goto-char'). + +Detects two pathological echoes from inner programs and aborts the +move (returning nil) after attempting recovery: +- `^[[C' literal in the buffer (inner program does not interpret + arrow keys): each right-arrow echoes as 4 visible characters; + send three backspaces per arrow sent to clean up. +- Cursor jumped past POS on right-arrow moves (bash autosuggest's + accept-on-right-arrow): send `C-_' to undo via readline. + +Only meaningful in `semi-char' input mode." + (when (and ghostel--term ghostel--cursor-pos) + (let* ((start-char-pos ghostel--cursor-char-pos) + (start-cursor ghostel--cursor-pos) + (start-col (car start-cursor)) + (start-row-vp (cdr start-cursor)) + (target-col (save-excursion (goto-char pos) (current-column))) + (target-row-vp (or (ghostel--viewport-row-at pos) start-row-vp)) + (dy (- target-row-vp start-row-vp)) + (dx (- target-col start-col)) + (right-arrow-drained nil) + (reached + (progn + (cond ((> dy 0) (dotimes (_ dy) (ghostel--send-encoded "down" ""))) + ((< dy 0) (dotimes (_ (abs dy)) (ghostel--send-encoded "up" "")))) + (cond ((> dx 0) (dotimes (_ dx) (ghostel--send-encoded "right" ""))) + ((< dx 0) (dotimes (_ (abs dx)) (ghostel--send-encoded "left" "")))) + ;; Verify landing only when there's reason to suspect a pathology + ;; (right-arrow moves can trigger literal-echo or autosuggest). + ;; Echo detection requires `ghostel--cursor-char-pos' (the + ;; rendered baseline) — when that's nil, treat the bulk send as + ;; success and let the post-success drain below settle point. + (if (or (<= dx 0) (null start-char-pos)) + t + (setq right-arrow-drained t) + (evil-ghostel--sync-render) + (let ((post-cur ghostel--cursor-char-pos)) + (cond + ;; Landed where expected — success. + ((and post-cur (= post-cur pos)) t) + ;; Literal-echo pattern: cursor advanced exactly 4×dx + ;; from start, and the buffer ends with "^[[C". Echo + ;; size is 4 (caret, [, [, C) per arrow; send 3 backspaces + ;; per arrow to undo, matching vterm's recovery. + ((and post-cur (zerop dy) + (= post-cur (+ start-char-pos (* 4 dx))) + (save-excursion + (goto-char post-cur) + (looking-back (regexp-quote "^[[C") (min 4 post-cur)))) + (dotimes (_ (* 3 dx)) (ghostel--send-encoded "backspace" "")) + nil) + ;; Cursor jumped past target — bash autosuggest accepted. + ((and post-cur (> post-cur pos)) + (ghostel--send-encoded "_" "ctrl") + nil) + ;; Anything else: didn't reach target, no recovery. + (t nil))))))) + (when reached + ;; Drain so `ghostel--cursor-pos' reflects the move before + ;; returning — but skip the drain when the right-arrow branch + ;; already drained, or when no arrows were sent at all. Drop + ;; point at the requested target directly; on success the + ;; terminal cursor reached POS, so POS is where point belongs + ;; (no dependency on the post-drain `ghostel--cursor-char-pos'). + (when (and (not right-arrow-drained) + (or (/= dx 0) (/= dy 0))) + (evil-ghostel--sync-render)) + (goto-char pos)) + reached))) + +(defun evil-ghostel-delete-input-region (beg end) + "Delete the BEG..END buffer range from input via the terminal PTY. +Moves the terminal cursor to END, then sends one backspace per +meaningful character (per `evil-ghostel--meaningful-input-length' — +see its docstring for the trailing-whitespace heuristic in +multi-line ranges). Leaves Emacs `point' at BEG so subsequent +commands (insert state entry, change → insert) see the cursor's new +buffer position rather than the pre-delete END. Returns the number +of backspaces sent. + +The buffer is not modified directly; the deletion takes effect once +the shell echoes the backspaces and the next redraw repaints the +input region. Only meaningful in `semi-char' input mode." + (let ((count (evil-ghostel--meaningful-input-length + (buffer-substring-no-properties beg end)))) + (when (> count 0) + (evil-ghostel-goto-input-position end) + (dotimes (_ count) + (ghostel--send-encoded "backspace" "")) + ;; Drain so `ghostel--cursor-pos' reflects the post-backspace + ;; cursor position before any caller (e.g. `evil-ghostel-insert' + ;; for `cc' / `cw') reads it and computes an arrow target from it. + (evil-ghostel--sync-render) + (goto-char beg)) + count)) + +(defun evil-ghostel-replace-input-region (beg end string) + "Replace the BEG..END range with STRING via the terminal PTY. +Deletes the range with `evil-ghostel-delete-input-region' then +pastes STRING through bracketed paste. Only meaningful in +`semi-char' input mode." + (let ((deleted (evil-ghostel-delete-input-region beg end))) + (when (and (> deleted 0) string (not (string-empty-p string))) + (ghostel--paste-text string)) + deleted)) + + +;; Motions + +(evil-define-motion evil-ghostel-beginning-of-line () + "Move point to the start of input on a prompt row. +On a row carrying the `ghostel-prompt' text property (OSC 133) or +inside line mode's input markers, jump past the prompt prefix to +the first input character. Otherwise fall through to +`evil-beginning-of-line' so column-0 navigation in scrollback and +non-prompt rows behaves as in vanilla evil." + :type exclusive + (if (or (evil-ghostel--active-p) + (evil-ghostel--line-mode-active-p)) + (ghostel-beginning-of-input-or-line) + (evil-beginning-of-line))) + +(evil-define-motion evil-ghostel-first-non-blank () + "Move point to the first non-blank character after the prompt. +On a prompt row, jumps past the prompt prefix; otherwise falls +through to `evil-first-non-blank'." + :type exclusive (if (or (evil-ghostel--active-p) (evil-ghostel--line-mode-active-p)) (ghostel-beginning-of-input-or-line) - (apply orig-fn args))) + (evil-first-non-blank))) + +(defun evil-ghostel--clamp-forward-motion (motion-fn count) + "Run MOTION-FN with COUNT, then clamp point to the cursor row's input. +Used by forward word motions in normal state so they stop at +`evil-ghostel--cursor-row-end-point' instead of scanning into the blank +renderer rows below the live prompt. + +Swallows `end-of-buffer'/`beginning-of-buffer' signals (vanilla +evil raises these on motion overshoot) and treats them as \"stop +where you are\" so the user doesn't get a noisy error every time +they `w' off the end of input." + (let* ((active (and (evil-ghostel--active-p) + (ghostel-point-on-cursor-row-p))) + (row-end (and active (evil-ghostel--cursor-row-end-point)))) + (condition-case _err + (funcall motion-fn count) + ((beginning-of-buffer end-of-buffer) nil)) + (when (and row-end (> (point) row-end)) + (goto-char row-end)))) + +(defun evil-ghostel--clamp-motion (motion-fn count) + "Run MOTION-FN with COUNT, then clamp point to the cursor row's input. +Like `evil-ghostel--clamp-forward-motion' but also clamps the left +side to `ghostel-input-start-point' so backward / horizontal / +end-of-line motions (`h', `l', `$') cannot walk into the prompt +prefix or into renderer cells past end-of-input. + +The lower-bound clamp only applies when point ended up on the +cursor row — if a backward motion left the row (e.g. `h' with +`evil-cross-lines' set), we don't teleport it back." + (let* ((active (and (evil-ghostel--active-p) + (ghostel-point-on-cursor-row-p))) + (row-end (and active (evil-ghostel--cursor-row-end-point))) + (row-start (and active (ghostel-input-start-point)))) + (condition-case _err + (funcall motion-fn count) + ((beginning-of-buffer end-of-buffer + beginning-of-line end-of-line) nil)) + (when (and row-end (> (point) row-end)) + (goto-char row-end)) + (when (and row-start (< (point) row-start) + (ghostel-point-on-cursor-row-p)) + (goto-char row-start)))) + +(evil-define-motion evil-ghostel-forward-word-begin (count) + "Forward to the start of the next word, clamped to the input row. +On the cursor row, never walks past `evil-ghostel--cursor-row-end-point' — +empty renderer rows below the prompt aren't treated as continuing +text. Off the cursor row, falls through to `evil-forward-word-begin'. + +Bound in normal state only; operator-pending state (e.g. `dw') uses +vanilla evil and lets `evil-ghostel--clamp-to-input' constrain the range." + :type exclusive + (evil-ghostel--clamp-forward-motion #'evil-forward-word-begin count)) + +(evil-define-motion evil-ghostel-forward-WORD-begin (count) + "Forward to the start of the next WORD, clamped to the input row. +See `evil-ghostel-forward-word-begin' for the clamp semantics." + :type exclusive + (evil-ghostel--clamp-forward-motion #'evil-forward-WORD-begin count)) + +(evil-define-motion evil-ghostel-forward-word-end (count) + "Forward to the end of the next word, clamped to the input row. +See `evil-ghostel-forward-word-begin' for the clamp semantics." + :type inclusive + (evil-ghostel--clamp-forward-motion #'evil-forward-word-end count)) + +(evil-define-motion evil-ghostel-forward-WORD-end (count) + "Forward to the end of the next WORD, clamped to the input row. +See `evil-ghostel-forward-word-begin' for the clamp semantics." + :type inclusive + (evil-ghostel--clamp-forward-motion #'evil-forward-WORD-end count)) + +(evil-define-motion evil-ghostel-forward-char (count) + "Move forward COUNT characters, clamped to the input row. +On the cursor row, never walks past `evil-ghostel--cursor-row-end-point' — +trailing renderer cells (stale glyphs from prior input, RPROMPT padding, +zsh-autosuggest hints) are not treated as text. Off the cursor row, +falls through to `evil-forward-char'." + :type exclusive + (evil-ghostel--clamp-motion #'evil-forward-char count)) + +(evil-define-motion evil-ghostel-backward-char (count) + "Move backward COUNT characters, clamped to the input row. +On the cursor row, never walks past `ghostel-input-start-point' so +the prompt prefix can't be entered. Off the cursor row, falls +through to `evil-backward-char'." + :type exclusive + (evil-ghostel--clamp-motion #'evil-backward-char count)) + +(evil-define-motion evil-ghostel-end-of-line (count) + "Move to end of line, clamped to the input row. +On the cursor row, stops at `evil-ghostel--cursor-row-end-point' so `$' +lands on the last typed character — not on trailing renderer cells. +Off the cursor row, falls through to `evil-end-of-line'." + :type inclusive + (evil-ghostel--clamp-motion #'evil-end-of-line count)) + +(evil-define-motion evil-ghostel-next-line (count) + "Move COUNT lines down, but not past the terminal cursor's row. +Prevents `j' from leaving the user stranded on empty renderer rows +below the live prompt. Falls through to `evil-next-line' outside +semi-char." + :type line + (if (not (evil-ghostel--active-p)) + (evil-next-line count) + (let ((cursor-line (evil-ghostel--cursor-buffer-line)) + (col (current-column))) + (condition-case _err + (evil-next-line count) + ((beginning-of-buffer end-of-buffer) nil)) + (when (and cursor-line + (> (- (line-number-at-pos (point) t) 1) cursor-line)) + (goto-char (point-min)) + (forward-line cursor-line) + (move-to-column col))))) + +(defun evil-ghostel-goto-cursor () + "Move point to the live terminal cursor. +Replaces `evil-goto-line' (typically the G key) in ghostel buffers — the natural +\"go to the prompt\" gesture in a terminal. Outside semi-char, +falls through to `evil-goto-line'." + (interactive) + (if (not (evil-ghostel--active-p)) + (call-interactively #'evil-goto-line) + (evil-ghostel--reset-cursor-point))) -;; Advice for evil insert-line / append-line - -(defun evil-ghostel--around-insert-line (orig-fn &rest args) - "Route `evil-insert-line' according to the current input mode. -ORIG-FN is the advised `evil-insert-line' called with ARGS. -In semi-char, sync the terminal cursor to point's row first so -Ctrl-a operates on the line the user navigated to (the multi-line -TUI case — without the row sync, kkI lands the cursor at the -start of the input's last line instead of at the line above). -Then send Ctrl-a so the shell moves its readline cursor to the -start of that input line — `orig-fn' enters insert state and the -buffer cursor is repositioned by the next redraw. -In line mode, the input region is plain buffer text bounded by -`ghostel--line-input-start' / `--line-input-end'; jump point there -and enter insert state directly (`back-to-indentation' would land -on the prompt, which is read-only). -Outside ghostel, run unchanged." +;; Insert / Append + +(defun evil-ghostel-insert () + "Enter insert state at point, driving the shell cursor to match. +On a non-cursor row (e.g. parked in scrollback), snap to +`ghostel-input-start-point' first so typed characters land at the +live prompt rather than overwriting scrollback. On the cursor +row, drive the shell cursor to point via +`evil-ghostel-goto-input-position', clamped to +`evil-ghostel--cursor-row-end-point' so `i' pressed on padding / +RPROMPT cells past typed input doesn't send arrows the shell will +silently clamp (which would desync Emacs `point' from the live +cursor). Outside semi-char, falls through to vanilla +`evil-insert'." + (interactive) + (cond + ((not (evil-ghostel--active-p)) + (call-interactively #'evil-insert)) + ((not (ghostel-point-on-cursor-row-p)) + (when-let* ((target (ghostel-input-start-point))) + (evil-ghostel-goto-input-position target)) + (evil-insert-state 1)) + (t + (let* ((row-end (evil-ghostel--cursor-row-end-point)) + (target (if row-end (min (point) row-end) (point)))) + (evil-ghostel-goto-input-position target)) + (evil-insert-state 1)))) + +(defun evil-ghostel-insert-line () + "Move to the start of the current input line, then enter insert state. +In semi-char, drives the shell cursor to `ghostel-input-start-point' +via arrow keys — analogous to vterm's `vterm-goto-char' shape, and +deterministic regardless of the shell's `bindkey -v' / vi-mode +configuration. In line mode, jumps point to +`ghostel--line-input-start'. Outside ghostel, runs vanilla +`evil-insert-line'." + (interactive) (cond ((evil-ghostel--active-p) - (evil-ghostel--cursor-to-point) - (ghostel--send-encoded "a" "ctrl") - (evil-ghostel--invalidate-shadow) - (setq evil-ghostel--sync-inhibit t) - (apply orig-fn args)) + (when-let* ((target (ghostel-input-start-point))) + (evil-ghostel-goto-input-position target)) + (evil-insert-state 1)) ((evil-ghostel--line-mode-active-p) (goto-char (marker-position ghostel--line-input-start)) - (setq evil-ghostel--sync-inhibit t) (evil-insert-state 1)) - (t (apply orig-fn args)))) - -(defun evil-ghostel--around-append-line (orig-fn &rest args) - "Route `evil-append-line' according to the current input mode. -ORIG-FN is the advised `evil-append-line' called with ARGS. -Symmetric to `evil-ghostel--around-insert-line': sync the terminal -cursor to point's row, then send Ctrl-e in semi-char; jump to -`--line-input-end' in line mode; otherwise unchanged." + (t (call-interactively #'evil-insert-line)))) + +(defun evil-ghostel-append () + "Append after point, driving the shell cursor to match. +On the cursor row the target is one cell right of point, clamped +to `evil-ghostel--cursor-row-end-point' so the cursor can't advance +onto renderer padding (RPROMPT, zsh-autosuggest hint, stale glyphs). +When point sits at or past the live cursor AND the cell at the +cursor is blank/eol, the target stays at point — vim's `a' advance +would otherwise visually park on a non-input cell. Off the cursor +row, snaps to `ghostel-input-start-point' first. Outside semi-char, +falls through to vanilla `evil-append'." + (interactive) + (cond + ((not (evil-ghostel--active-p)) + (call-interactively #'evil-append)) + ((not (ghostel-point-on-cursor-row-p)) + (when-let* ((target (ghostel-input-start-point))) + (evil-ghostel-goto-input-position target)) + (evil-insert-state 1)) + (t + (let* ((cur (ghostel-cursor-point)) + (target + (if (and cur (>= (point) cur) + (save-excursion + (goto-char cur) + (or (eolp) (looking-at-p "[ \t]")))) + (point) + (let ((row-end (evil-ghostel--cursor-row-end-point))) + (min (1+ (point)) (or row-end (1+ (point)))))))) + (evil-ghostel-goto-input-position target)) + (evil-insert-state 1)))) + +(defun evil-ghostel-append-line () + "Move to the end of the current input line, then enter insert state. +Symmetric to `evil-ghostel-insert-line': drives the shell cursor +to `evil-ghostel--cursor-row-end-point' (end of typed input on the +cursor row) via arrow keys. Line mode jumps to +`ghostel--line-input-end'." + (interactive) (cond ((evil-ghostel--active-p) - (evil-ghostel--cursor-to-point) - (ghostel--send-encoded "e" "ctrl") - (evil-ghostel--invalidate-shadow) - (setq evil-ghostel--sync-inhibit t) - (apply orig-fn args)) + (when-let* ((target (evil-ghostel--cursor-row-end-point))) + (evil-ghostel-goto-input-position target)) + (evil-insert-state 1)) ((evil-ghostel--line-mode-active-p) (goto-char (marker-position ghostel--line-input-end)) - (setq evil-ghostel--sync-inhibit t) (evil-insert-state 1)) - (t (apply orig-fn args)))) + (t (call-interactively #'evil-append-line)))) -;; Editing primitives +;; Delete + +(evil-define-operator evil-ghostel-delete + (beg end type register yank-handler) + "Delete BEG..END via the PTY (semi-char) or fall through to `evil-delete'. +The range is first clamped to the editable input region by +`evil-ghostel--clamp-to-input', so motion overshoot (e.g. `cw' walking +past end-of-input) cannot over-delete past the live cursor. + +For line-type deletes on the cursor row, uses readline's Ctrl-e +Ctrl-u shortcut to clear the input area in a single round-trip. +Block-type deletes apply `evil-ghostel-delete-input-region' per block +row. All other ranges go through `evil-ghostel-delete-input-region'. + +Covers d, dd, x, X." + (interactive "") + (if (not (evil-ghostel--active-p)) + (evil-delete beg end type register yank-handler) + (let* ((clamped (evil-ghostel--clamp-to-input (cons beg end))) + (beg (car clamped)) + (end (cdr clamped))) + (unless register + (let ((text (filter-buffer-substring beg end))) + (unless (string-match-p "\n" text) + (evil-set-register ?- text)))) + (let ((evil-was-yanked-without-register nil)) + (evil-yank beg end type register yank-handler)) + (cond + ((eq type 'block) + (evil-apply-on-block #'evil-ghostel-delete-input-region beg end nil)) + ;; Line-type on the cursor row goes through the same + ;; `delete-input-region' path as every other delete — + ;; vterm-collection's shape. `beg' / `end' are already + ;; clamped to [input-start, row-end] by `clamp-to-input' + ;; above, so the backspace count equals the typed-input + ;; length; `delete-input-region' then leaves point at + ;; `beg' (= input-start), so a subsequent + ;; `evil-ghostel-insert' (for cc / S / visual-c) + ;; finds point already where it needs to be. + (t (evil-ghostel-delete-input-region beg end)))))) + +(evil-define-operator evil-ghostel-delete-line + (beg end type register yank-handler) + "Delete from point through end of line, PTY-routed in semi-char. +In visual state, the range is first expanded to a linewise range +matching vanilla `evil-delete-line'. Otherwise routes through +`evil-ghostel-delete' with END extended to the end of the cursor's +line. + +Covers D." + :motion nil + :keep-visual t + (interactive "") + (if (not (evil-ghostel--active-p)) + (evil-delete-line beg end type register yank-handler) + (let* ((beg (or beg (point))) + (end (or end beg)) + (line-end (save-excursion (goto-char beg) (line-end-position)))) + (when (evil-visual-state-p) + (unless (memq type '(line screen-line block)) + (let ((range (evil-expand beg end 'line))) + (setq beg (evil-range-beginning range) + end (evil-range-end range) + type (evil-type range)))) + (evil-exit-visual-state)) + (cond + ((eq type 'block) + (evil-ghostel-delete beg end 'block register yank-handler)) + ((memq type '(line screen-line)) + (evil-ghostel-delete beg end type register yank-handler)) + (t + (evil-ghostel-delete beg line-end type register yank-handler)))))) + +(evil-define-operator evil-ghostel-delete-char (beg end type register) + "Delete the current character. PTY-routed in semi-char." + :motion evil-forward-char + (interactive "") + (evil-ghostel-delete beg end type register)) + +(evil-define-operator evil-ghostel-delete-backward-char (beg end type register) + "Delete the previous character. PTY-routed in semi-char." + :motion evil-backward-char + (interactive "") + (evil-ghostel-delete beg end type register)) -(defun evil-ghostel--meaningful-length (text) - "Length of TEXT, stripping per-line trailing whitespace in multi-line ranges. -Heuristic for TUIs that draw a fixed-width input box wider than the -user's typed text (e.g. prompt_toolkit-based REPLs that fill each -input row out to the box's right border). The trailing spaces end -up in the Emacs buffer because the terminal explicitly wrote them -\(see `src/render.zig' — only unwritten cells are trimmed), but -they are not characters in the TUI's input model, and sending one -backspace per buffer character would eat far past the actual input. + +;; Change + +(evil-define-operator evil-ghostel-change + (beg end type register yank-handler delete-func) + "Change BEG..END via the PTY then enter insert state. +PTY-routed in semi-char; falls through to `evil-change' otherwise. +`evil-ghostel-insert' drives the shell cursor to point itself, so +empty-range cases (e.g. C at end-of-line on a non-cursor row) need +no extra synchronization here. + +Covers c, cc, s." + (interactive "") + (if (not (evil-ghostel--active-p)) + (evil-change beg end type register yank-handler delete-func) + (evil-ghostel-delete beg end type register yank-handler) + (evil-ghostel-insert))) + +(evil-define-operator evil-ghostel-change-line + (beg end type register yank-handler) + "Change from point through end of line. PTY-routed in semi-char. + +Covers C." + :motion evil-end-of-line-or-visual-line + (interactive "") + (if (not (evil-ghostel--active-p)) + (evil-change-line beg end type register yank-handler) + (evil-ghostel-delete-line beg end type register yank-handler) + (evil-ghostel-insert))) + +(evil-define-operator evil-ghostel-substitute (beg end type register) + "Substitute the next character. Covers s." + :motion evil-forward-char + (interactive "") + (evil-ghostel-change beg end type register)) + +(evil-define-operator evil-ghostel-substitute-line + (beg end register yank-handler) + "Substitute the current line. Covers S." + :motion evil-line-or-visual-line + :type line + (interactive "") + (evil-ghostel-change beg end 'line register yank-handler)) -Only applied when TEXT spans more than one buffer line. In a -single-line range (e.g. `dw' deleting `\"word \"'), trailing -whitespace is treated as real user-typed content and counted — -otherwise we'd send one fewer backspace than the deletion needs -and leave a stray character behind. - -Tradeoff: a line of pure user-typed indentation inside a multi-line -range (e.g. `\" \\nfoo\"') collapses on the first line and -contributes 0 backspaces. Acceptable cost — the alternative -over-deletes on every prompt_toolkit-style TUI." - (if (string-match-p "\n" text) - (length (replace-regexp-in-string "[ \t]+\\(\n\\|\\'\\)" "\\1" text)) - (length text))) + +;; Replace + +(evil-define-operator evil-ghostel-replace (beg end type char) + "Replace BEG..END with CHAR via the PTY. Covers r. +Reads CHAR via the `' interactive code, then issues a +delete-then-paste sequence so the replacement count matches the +deletion count (trailing whitespace stripped by +`evil-ghostel--meaningful-input-length' in multi-line ranges does not +get re-added by the paste)." + :motion evil-forward-char + (interactive "") + (if (not (evil-ghostel--active-p)) + (evil-replace beg end type char) + (when char + (let* ((clamped (evil-ghostel--clamp-to-input (cons beg end))) + (b (car clamped)) + (e (cdr clamped)) + (count (evil-ghostel--meaningful-input-length + (buffer-substring-no-properties b e)))) + (when (> count 0) + (evil-ghostel-replace-input-region b e (make-string count char))))))) -(defun evil-ghostel--delete-region (beg end) - "Delete text between BEG and END via the terminal PTY. -Moves terminal cursor to END, then sends one backspace per -meaningful character (see `evil-ghostel--meaningful-length'). -Uses backspace rather than forward-delete because the Delete key -escape sequence is not bound in all shell configurations. - -Updates `evil-ghostel--shadow-cursor' to reflect the post-backspace -position — each backspace moves the cursor one column left without -crossing rows in the cases we care about (readline clamps at start -of input)." - (let ((count (evil-ghostel--meaningful-length - (buffer-substring-no-properties beg end)))) - (when (> count 0) - (goto-char end) - (evil-ghostel--cursor-to-point) - (dotimes (_ count) - (ghostel--send-encoded "backspace" "")) - (goto-char beg) - (when evil-ghostel--shadow-cursor - (setcar evil-ghostel--shadow-cursor - (max 0 (- (car evil-ghostel--shadow-cursor) count))))))) - -(defun evil-ghostel--point-on-cursor-row-p () - "Non-nil when Emacs point is on the same viewport row as the terminal cursor. -Used by line-type `dd' / `cc' to dispatch between the readline-aware -Ctrl-e/Ctrl-u shortcut (when point is on the cursor's line — the -typical single-line shell case) and the explicit cursor-sync + -backspace path (when point is on a different line, the multi-line -TUI case from issue #218)." - (when ghostel--term - (let* ((tpos ghostel--cursor-pos) - (trow (cdr tpos)) - (scrollback (if ghostel--term-rows - (max 0 (- (count-lines (point-min) (point-max)) - ghostel--term-rows)) - 0)) - (prow (- (line-number-at-pos (point) t) 1 scrollback))) - (= prow trow)))) - -(defun evil-ghostel--clear-input-line () - "Clear the active input line via Ctrl-e Ctrl-u. -Readline / zle / prompt_toolkit all bind this to \"go to end of -line, then kill from start of line to cursor\" — so the active -input is cleared without us needing to know where the prompt ends. -Sets `evil-ghostel--sync-point-on-next-redraw' so the redraw -triggered by the shell's echo lands point at the new cursor -position (start of the input area) rather than leaving it on the -prompt at column 0." - (ghostel--send-encoded "e" "ctrl") - (ghostel--send-encoded "u" "ctrl") - (evil-ghostel--invalidate-shadow) - (setq evil-ghostel--sync-point-on-next-redraw t)) + +;; Paste + +(defun evil-ghostel-paste-after (&optional count register yank-handler) + "Paste after the cursor via bracketed paste. Covers p. +COUNT pastes the register / kill ring entry that many times. +REGISTER selects a specific register; YANK-HANDLER is forwarded to +`evil-paste-after' in the fall-through path." + (interactive "*P") + (if (not (evil-ghostel--active-p)) + (evil-paste-after count register yank-handler) + (let ((text (if register + (evil-get-register register) + (current-kill 0))) + (n (prefix-numeric-value count))) + (when text + (evil-ghostel-goto-input-position (point)) + (ghostel--send-encoded "right" "") + (dotimes (_ n) + (ghostel--paste-text text)))))) + +(defun evil-ghostel-paste-before (&optional count register yank-handler) + "Paste before the cursor via bracketed paste. Covers P. +COUNT pastes the register / kill ring entry that many times. +REGISTER selects a specific register; YANK-HANDLER is forwarded to +`evil-paste-before' in the fall-through path." + (interactive "*P") + (if (not (evil-ghostel--active-p)) + (evil-paste-before count register yank-handler) + (let ((text (if register + (evil-get-register register) + (current-kill 0))) + (n (prefix-numeric-value count))) + (when text + (evil-ghostel-goto-input-position (point)) + (dotimes (_ n) + (ghostel--paste-text text)))))) -;; Advice for evil editing operators - -(defun evil-ghostel--around-delete - (orig-fn beg end &optional type register yank-handler) - "Intercept `evil-delete' in ghostel buffers. -ORIG-FN is the advised `evil-delete' called with BEG, END, TYPE, -REGISTER, and YANK-HANDLER. -Yanks text to REGISTER, then deletes via PTY. -Covers d, dd, D, x, X." - (if (evil-ghostel--active-p) - (progn - (unless register - (let ((text (filter-buffer-substring beg end))) - (unless (string-match-p "\n" text) - (evil-set-register ?- text)))) - (let ((evil-was-yanked-without-register nil)) - (evil-yank beg end type register yank-handler)) - (if (and (eq type 'line) (evil-ghostel--point-on-cursor-row-p)) - ;; Single-line shell case: readline shortcut clears the - ;; input area without us needing prompt geometry. - (evil-ghostel--clear-input-line) - ;; Multi-line case (point on a different row from the - ;; terminal cursor): sync cursor to the deleted region's - ;; end then backspace through it. - (evil-ghostel--delete-region beg end))) - (funcall orig-fn beg end type register yank-handler))) - -(defun evil-ghostel--around-change - (orig-fn beg end type register yank-handler &optional delete-func) - "Intercept `evil-change' in ghostel buffers. -ORIG-FN is the advised `evil-change' called with BEG, END, TYPE, -REGISTER, YANK-HANDLER, and DELETE-FUNC. -Deletes via PTY, then enters insert state. -Covers c, cc, C, s, S. - -When `evil-ghostel--delete-region' actually sends keys (count > 0), -it leaves point and the shadow cursor at BEG, so insert state will -land on the correct row. Only when the range is empty (count = 0, -e.g. \\\\[evil-change-line] at end-of-line on -a non-cursor row) do we explicitly sync the terminal cursor — -otherwise typed characters would land on whatever row the cursor -was last parked on. The line-type Ctrl-u branch runs its own -redraw-time sync via `evil-ghostel--sync-point-on-next-redraw'." - (if (evil-ghostel--active-p) - (progn - (let ((evil-was-yanked-without-register nil)) - (evil-yank beg end type register yank-handler)) - (cond - ((and (eq type 'line) (evil-ghostel--point-on-cursor-row-p)) - (evil-ghostel--clear-input-line)) - (t - (let ((count (evil-ghostel--meaningful-length - (buffer-substring-no-properties beg end)))) - (evil-ghostel--delete-region beg end) - (when (zerop count) - (evil-ghostel--cursor-to-point))))) - (setq evil-ghostel--sync-inhibit t) - (evil-insert 1)) - (funcall orig-fn beg end type register yank-handler delete-func))) - -(defun evil-ghostel--around-replace (orig-fn beg end type char) - "Intercept `evil-replace' in ghostel buffers. -ORIG-FN is the advised `evil-replace' called with BEG, END, TYPE, -and CHAR. -Deletes the range, then inserts replacement characters. -The paste count must match the delete count — both go through -`evil-ghostel--meaningful-length' so trailing whitespace stripped -from the deletion isn't re-added by the paste." - (if (evil-ghostel--active-p) - (when char - (let ((count (evil-ghostel--meaningful-length - (buffer-substring-no-properties beg end)))) - (evil-ghostel--delete-region beg end) - (when (> count 0) - (ghostel--paste-text (make-string count char)) - (evil-ghostel--invalidate-shadow)))) - (funcall orig-fn beg end type char))) - -(defun evil-ghostel--around-paste-after - (orig-fn count &optional register yank-handler) - "Intercept `evil-paste-after' in ghostel buffers. -ORIG-FN is the advised `evil-paste-after' called with COUNT, -REGISTER, and YANK-HANDLER. -Pastes from REGISTER via the terminal PTY." - (if (evil-ghostel--active-p) - (let ((text (if register - (evil-get-register register) - (current-kill 0))) - (count (prefix-numeric-value count))) - (when text - (evil-ghostel--cursor-to-point) - (ghostel--send-encoded "right" "") - (dotimes (_ count) - (ghostel--paste-text text)) - (evil-ghostel--invalidate-shadow))) - (funcall orig-fn count register yank-handler))) - -(defun evil-ghostel--around-paste-before - (orig-fn count &optional register yank-handler) - "Intercept `evil-paste-before' in ghostel buffers. -ORIG-FN is the advised `evil-paste-before' called with COUNT, -REGISTER, and YANK-HANDLER. -Pastes from REGISTER via the terminal PTY." - (if (evil-ghostel--active-p) - (let ((text (if register - (evil-get-register register) - (current-kill 0))) - (count (prefix-numeric-value count))) - (when text - (evil-ghostel--cursor-to-point) - (dotimes (_ count) - (ghostel--paste-text text)) - (evil-ghostel--invalidate-shadow))) - (funcall orig-fn count register yank-handler))) +;; Undo / Redo + +(defun evil-ghostel-undo (count) + "Send Ctrl-_ (readline undo) COUNT times. Covers u. +Falls through to `evil-undo' outside semi-char." + (interactive "p") + (if (not (evil-ghostel--active-p)) + (evil-undo count) + (dotimes (_ (or count 1)) + (ghostel--send-encoded "_" "ctrl")))) + +(defun evil-ghostel-redo (count) + "Redo is not supported in the terminal. +COUNT is forwarded to `evil-redo' in the fall-through path." + (interactive "p") + (if (not (evil-ghostel--active-p)) + (evil-redo count) + (message "Redo not supported in terminal"))) -;; Insert-state Ctrl key passthrough +;; Keymap and insert-state Ctrl passthrough (defvar evil-ghostel-mode-map (make-sparse-keymap) "Keymap for `evil-ghostel-mode'. -Insert-state Ctrl key bindings are set up via `evil-define-key*'.") +Bindings for normal/visual editing commands and insert-state Ctrl +passthrough are installed via `evil-define-key*'.") (defconst evil-ghostel--ctrl-passthrough-keys - '("a" "d" "e" "k" "n" "p" "r" "t" "u" "w" "y") + '("a" "b" "d" "e" "f" "k" "l" "n" "o" "p" "q" "r" "s" "t" "u" "v" "w" "y") "Ctrl+key combinations to pass through to the terminal in insert state. These keys all have standard readline/zle bindings (C-a beginning-of-line, -C-d EOF, C-e end-of-line, C-k kill-line, etc.) that would otherwise be -intercepted by evil's insert-state commands.") +C-d EOF, C-e end-of-line, C-k kill-line, C-l clear-screen, etc.) that would +otherwise be intercepted by evil's insert-state commands. Mirrors vterm's +passthrough set with one exception: `C-z' is intentionally left to evil +so `evil-emacs-state' (the default `evil-toggle-key' binding) remains +reachable as an escape hatch.") (defun evil-ghostel--passthrough-ctrl (key) "Send Ctrl+KEY to the terminal PTY, or fall back to evil's binding. @@ -637,12 +1107,7 @@ own bindings (e.g. \\`C-a' → `ghostel-beginning-of-input-or-line', defaults; without that, the minor-mode aux map containing this passthrough would shadow line mode's local-map binding." (if (evil-ghostel--active-p) - (progn - (ghostel--send-encoded key "ctrl") - ;; C-a / C-e / C-u / C-w / C-r / C-n / C-p all reposition the - ;; readline cursor (or load a different input line entirely); - ;; the shadow's pre-keystroke baseline is no longer valid. - (evil-ghostel--invalidate-shadow)) + (ghostel--send-encoded key "ctrl") (let* ((vec (kbd (concat "C-" key))) (local (current-local-map)) (cmd (or (and local (lookup-key local vec)) @@ -660,21 +1125,75 @@ passthrough would shadow line mode's local-map binding." (evil-ghostel--passthrough-ctrl k)) (format "Send C-%s to the terminal or fall back to evil." k))))) -(defun evil-ghostel--around-undo (orig-fn count) - "Intercept `evil-undo' in ghostel buffers. -ORIG-FN is the advised `evil-undo' called with COUNT. -Sends Ctrl+_ (readline undo) COUNT times." +(defun evil-ghostel--passthrough-delete () + "Send `' to the terminal PTY in semi-char, else fall back to evil. +Evil's insert-state map binds `' to `delete-char', which would +edit buffer text rather than forward-delete in the shell. In line mode, +falls through to whatever the local map binds (e.g. `delete-char')." + (interactive) (if (evil-ghostel--active-p) - (dotimes (_ (or count 1)) - (ghostel--send-encoded "_" "ctrl")) - (funcall orig-fn count))) + (ghostel--send-encoded "delete" "") + (let* ((vec (kbd "")) + (local (current-local-map)) + (cmd (or (and local (lookup-key local vec)) + (lookup-key evil-insert-state-map vec)))) + (when (commandp cmd) + (call-interactively cmd))))) -(defun evil-ghostel--around-redo (orig-fn count) - "Intercept `evil-redo' in ghostel buffers. -ORIG-FN is the advised `evil-redo' called with COUNT." - (if (evil-ghostel--active-p) - (message "Redo not supported in terminal") - (funcall orig-fn count))) +(evil-define-key* 'insert evil-ghostel-mode-map + (kbd "") #'evil-ghostel--passthrough-delete) + +;; Editing operators and insert/append commands in normal + visual. +;; +;; Bindings use `[remap evil-FOO]' rather than literal keys so user +;; remappings of the underlying evil commands flow through to our +;; PTY-routed variants. A user with `(define-key evil-normal-state-map +;; "x" #'some-cmd)' won't have their binding clobbered — the remap only +;; fires when evil would have dispatched to `evil-delete-char' etc. +(evil-define-key* '(normal visual) evil-ghostel-mode-map + [remap evil-delete] #'evil-ghostel-delete + [remap evil-delete-line] #'evil-ghostel-delete-line + [remap evil-delete-char] #'evil-ghostel-delete-char + [remap evil-delete-backward-char] #'evil-ghostel-delete-backward-char + [remap evil-change] #'evil-ghostel-change + [remap evil-change-line] #'evil-ghostel-change-line + [remap evil-substitute] #'evil-ghostel-substitute + [remap evil-change-whole-line] #'evil-ghostel-substitute-line + [remap evil-replace] #'evil-ghostel-replace + [remap evil-paste-after] #'evil-ghostel-paste-after + [remap evil-paste-before] #'evil-ghostel-paste-before + [remap evil-undo] #'evil-ghostel-undo + [remap evil-redo] #'evil-ghostel-redo) + +;; Insert/append are normal-only (visual has its own behaviour for `i'). +(evil-define-key* 'normal evil-ghostel-mode-map + [remap evil-insert] #'evil-ghostel-insert + [remap evil-insert-line] #'evil-ghostel-insert-line + [remap evil-append] #'evil-ghostel-append + [remap evil-append-line] #'evil-ghostel-append-line) + +;; Motion clamps and j / G overrides are normal-only — operator-pending +;; state uses vanilla evil so motions can overshoot freely and the +;; operator's `evil-ghostel--clamp-to-input' trims the range. Without this +;; scoping the clamp here would suppress overshoot before the operator sees it, +;; which broke `cw' in the noctuid regression that the rewrite avoided. +(evil-define-key* 'normal evil-ghostel-mode-map + [remap evil-forward-word-begin] #'evil-ghostel-forward-word-begin + [remap evil-forward-WORD-begin] #'evil-ghostel-forward-WORD-begin + [remap evil-forward-word-end] #'evil-ghostel-forward-word-end + [remap evil-forward-WORD-end] #'evil-ghostel-forward-WORD-end + [remap evil-forward-char] #'evil-ghostel-forward-char + [remap evil-backward-char] #'evil-ghostel-backward-char + [remap evil-end-of-line] #'evil-ghostel-end-of-line + [remap evil-next-line] #'evil-ghostel-next-line + [remap evil-goto-line] #'evil-ghostel-goto-cursor + "[[" #'ghostel-previous-prompt + "]]" #'ghostel-next-prompt) + +;; Motions also reachable in operator-pending so `d0' / `d^' work. +(evil-define-key* '(normal visual operator motion) evil-ghostel-mode-map + [remap evil-beginning-of-line] #'evil-ghostel-beginning-of-line + [remap evil-first-non-blank] #'evil-ghostel-first-non-blank) ;; ESC routing: terminal vs evil @@ -734,11 +1253,30 @@ The mode is buffer-local; see `evil-ghostel-escape' for the default." ;; Minor mode +(defun evil-ghostel--any-active-elsewhere-p (except-buffer) + "Return non-nil if any buffer other than EXCEPT-BUFFER has the mode on. +Used to decide whether the global advice on `ghostel--redraw' and +`ghostel--set-cursor-style' can be removed when EXCEPT-BUFFER +disables `evil-ghostel-mode'." + (catch 'found + (dolist (b (buffer-list)) + (when (and (not (eq b except-buffer)) + (buffer-local-value 'evil-ghostel-mode b)) + (throw 'found t))))) + ;;;###autoload (define-minor-mode evil-ghostel-mode "Minor mode for evil integration in ghostel terminal buffers. -Synchronizes the terminal cursor with Emacs point during evil -state transitions." +Binds `evil-ghostel-*' operators / motions / commands in `evil-ghostel-mode-map' +and syncs the terminal cursor with Emacs point during evil state transitions. + +The mode advises two global functions, `ghostel--redraw' and +`ghostel--set-cursor-style', to preserve point and override the +cursor style for evil state. Because advice is global but the +mode is buffer-local, the advice is installed on first enable +and removed only when the *last* `evil-ghostel-mode' buffer +disables — otherwise toggling the mode off in one buffer would +silently strip the wrapper from every other ghostel buffer." :lighter nil :keymap evil-ghostel-mode-map (if evil-ghostel-mode @@ -751,19 +1289,8 @@ state transitions." ;; states expect point to follow the terminal cursor. (add-hook 'evil-emacs-state-entry-hook #'evil-ghostel--insert-state-entry nil t) - (advice-add 'evil-insert-line :around #'evil-ghostel--around-insert-line) - (advice-add 'evil-append-line :around #'evil-ghostel--around-append-line) - (advice-add 'evil-beginning-of-line :around - #'evil-ghostel--around-beginning-of-line) - (advice-add 'evil-first-non-blank :around - #'evil-ghostel--around-beginning-of-line) - (advice-add 'evil-delete :around #'evil-ghostel--around-delete) - (advice-add 'evil-change :around #'evil-ghostel--around-change) - (advice-add 'evil-replace :around #'evil-ghostel--around-replace) - (advice-add 'evil-paste-after :around #'evil-ghostel--around-paste-after) - (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) + ;; `advice-add' is idempotent on (symbol, fn) pairs, so calling + ;; it on every enable is safe and avoids a separate install flag. (advice-add 'ghostel--redraw :around #'evil-ghostel--around-redraw) (advice-add 'ghostel--set-cursor-style :around #'evil-ghostel--override-cursor-style) @@ -772,22 +1299,10 @@ state transitions." #'evil-ghostel--insert-state-entry t) (remove-hook 'evil-emacs-state-entry-hook #'evil-ghostel--insert-state-entry t) - (advice-remove 'evil-insert-line #'evil-ghostel--around-insert-line) - (advice-remove 'evil-append-line #'evil-ghostel--around-append-line) - (advice-remove 'evil-beginning-of-line - #'evil-ghostel--around-beginning-of-line) - (advice-remove 'evil-first-non-blank - #'evil-ghostel--around-beginning-of-line) - (advice-remove 'evil-delete #'evil-ghostel--around-delete) - (advice-remove 'evil-change #'evil-ghostel--around-change) - (advice-remove 'evil-replace #'evil-ghostel--around-replace) - (advice-remove 'evil-paste-after #'evil-ghostel--around-paste-after) - (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) - (advice-remove 'ghostel--redraw #'evil-ghostel--around-redraw) - (advice-remove 'ghostel--set-cursor-style - #'evil-ghostel--override-cursor-style))) + (unless (evil-ghostel--any-active-elsewhere-p (current-buffer)) + (advice-remove 'ghostel--redraw #'evil-ghostel--around-redraw) + (advice-remove 'ghostel--set-cursor-style + #'evil-ghostel--override-cursor-style)))) (provide 'evil-ghostel) ;;; evil-ghostel.el ends here diff --git a/test/evil-ghostel-test.el b/test/evil-ghostel-test.el index af0c639e..74529175 100644 --- a/test/evil-ghostel-test.el +++ b/test/evil-ghostel-test.el @@ -60,14 +60,40 @@ Uses mocks for native functions." ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-mode-activation () - "Test that `evil-ghostel-mode' activates correctly." + "Test that `evil-ghostel-mode' activates correctly. +Asserts that the insert-state-entry hook is wired up, the redraw +advice is installed, and the command-remap bindings are in +`evil-ghostel-mode-map' for normal and visual states. + +Bindings are remap-form (`[remap evil-FOO]') so user remappings of +the underlying evil commands flow through to our PTY-routed +variants — verified here by looking up the remap rather than a +literal key." (evil-ghostel-test--with-evil-buffer (should evil-ghostel-mode) (should (memq 'evil-ghostel--insert-state-entry evil-insert-state-entry-hook)) - (should (advice--p (advice--symbol-function 'evil-insert-line))) (should (advice--p (advice--symbol-function 'ghostel--redraw))) - (should (advice--p (advice--symbol-function 'ghostel--set-cursor-style))))) + (should (advice--p (advice--symbol-function 'ghostel--set-cursor-style))) + ;; Editing operators are bound via [remap evil-FOO] in normal state. + (should (eq #'evil-ghostel-delete + (lookup-key (evil-get-auxiliary-keymap + evil-ghostel-mode-map 'normal) + [remap evil-delete]))) + (should (eq #'evil-ghostel-change + (lookup-key (evil-get-auxiliary-keymap + evil-ghostel-mode-map 'normal) + [remap evil-change]))) + ;; And in visual state. + (should (eq #'evil-ghostel-delete + (lookup-key (evil-get-auxiliary-keymap + evil-ghostel-mode-map 'visual) + [remap evil-delete]))) + ;; Literal key bindings must NOT be present — that would shadow + ;; user remappings of the underlying evil commands. + (should-not (lookup-key (evil-get-auxiliary-keymap + evil-ghostel-mode-map 'normal) + "d")))) (ert-deftest evil-ghostel-test-mode-activation-no-normal-entry-hook () "`evil-ghostel-mode' does not install a `normal-state-entry-hook'. @@ -86,6 +112,43 @@ overwrite the position evil assigns at operator/visual completion." (should-not (memq 'evil-ghostel--insert-state-entry evil-insert-state-entry-hook)))) +(ert-deftest evil-ghostel-test-advice-survives-disable-in-other-buffer () + "Global `ghostel--redraw' / cursor-style advice survives one buffer disabling. +The advice is global but the mode is buffer-local; `advice-remove' +during disable must wait until the LAST `evil-ghostel-mode' buffer +is gone, otherwise toggling off in one buffer silently strips the +wrapper from every other ghostel buffer." + (let ((a (generate-new-buffer " *evil-ghostel-test-advice-a*")) + (b (generate-new-buffer " *evil-ghostel-test-advice-b*"))) + (unwind-protect + (progn + (with-current-buffer a + (ghostel-mode) + (setq-local ghostel--term-rows 100) + (evil-local-mode 1) + (evil-ghostel-mode 1)) + (with-current-buffer b + (ghostel-mode) + (setq-local ghostel--term-rows 100) + (evil-local-mode 1) + (evil-ghostel-mode 1)) + (should (advice-member-p #'evil-ghostel--around-redraw + 'ghostel--redraw)) + ;; Disable in A — B still has the mode on, advice must stay. + (with-current-buffer a (evil-ghostel-mode -1)) + (should (advice-member-p #'evil-ghostel--around-redraw + 'ghostel--redraw)) + (should (advice-member-p #'evil-ghostel--override-cursor-style + 'ghostel--set-cursor-style)) + ;; Disable in B — no buffers left, advice removed. + (with-current-buffer b (evil-ghostel-mode -1)) + (should-not (advice-member-p #'evil-ghostel--around-redraw + 'ghostel--redraw)) + (should-not (advice-member-p #'evil-ghostel--override-cursor-style + 'ghostel--set-cursor-style))) + (when (buffer-live-p a) (kill-buffer a)) + (when (buffer-live-p b) (kill-buffer b))))) + ;; ----------------------------------------------------------------------- ;; Test: initial-state defcustom ;; ----------------------------------------------------------------------- @@ -323,66 +386,34 @@ point in the scrollback region instead of the visible viewport." (current-column)))))) ;; ----------------------------------------------------------------------- -;; Test: cursor-to-point (arrow key sending) +;; Test: evil-ghostel-goto-input-position end-to-end with the native module ;; ----------------------------------------------------------------------- -(ert-deftest evil-ghostel-test-cursor-to-point () - "Test that `evil-ghostel--cursor-to-point' sends correct arrow keys." +(ert-deftest evil-ghostel-test-goto-input-position-end-to-end () + "End-to-end: `evil-ghostel-goto-input-position' sends LEFT arrows. +Verifies the lifted-from-evil-ghostel implementation against a real +libghostty terminal (the Phase 1 mock tests exercise the bare +algorithm; this one walks scrollback math and viewport offsets too)." (evil-ghostel-test--with-buffer 5 40 "$ echo hello world" - ;; Terminal cursor at col 18, row 0 (should (equal '(18 . 0) ghostel--cursor-pos)) - ;; Move point to col 7 (start of "hello") - (goto-char (point-min)) - (move-to-column 7) - ;; Track what keys are sent (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-ghostel--cursor-to-point)) - ;; Should send 11 LEFT arrows (18 - 7 = 11) + ;; Target: position 8 = column 7 + ;; (start of "hello"). + (evil-ghostel-goto-input-position 8)) (should (= 11 (length keys-sent))) (should (cl-every (lambda (k) (equal k "left")) keys-sent))))) -(ert-deftest evil-ghostel-test-cursor-to-point-right () - "Test arrow key sending when point is to the right of terminal cursor." - (evil-ghostel-test--with-buffer 5 40 "hello" - ;; Terminal cursor at col 5 - ;; Move cursor left in terminal, then redraw so ghostel--cursor-pos - ;; reflects the new position (col 2). - (ghostel--write-input term "\e[3D") ; cursor left 3 → col 2 - (let ((inhibit-read-only t)) (ghostel--redraw term t)) - (goto-char (point-min)) - (move-to-column 4) ; point at col 4 - (let ((keys-sent '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) - (push key keys-sent)))) - (evil-ghostel--cursor-to-point)) - ;; Should send 2 RIGHT arrows (4 - 2 = 2) - (should (= 2 (length keys-sent))) - (should (cl-every (lambda (k) (equal k "right")) keys-sent))))) - -(ert-deftest evil-ghostel-test-cursor-to-point-no-op () - "Test that no arrows are sent when point matches terminal cursor." - (evil-ghostel-test--with-buffer 5 40 "hello" - ;; Point is already at terminal cursor after redraw - (let ((keys-sent '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) - (push key keys-sent)))) - (evil-ghostel--cursor-to-point)) - (should (= 0 (length keys-sent)))))) - -(ert-deftest evil-ghostel-test-cursor-to-point-with-scrollback () - "Regression: cursor-to-point must subtract scrollback from buffer line. -`ghostel--cursor-pos' holds viewport-relative rows, so a -buffer line N must be converted to viewport row N-scrollback before -diffing — otherwise dy is wrong by exactly the scrollback line count -and the helper sends arrows that move the cursor off the input." +(ert-deftest evil-ghostel-test-goto-input-position-with-scrollback () + "Regression: goto-input-position must subtract scrollback from buffer line. +`ghostel--cursor-pos' holds viewport-relative rows, so a buffer +line N must be converted to viewport row N-scrollback before +diffing — otherwise dy is wrong by the scrollback line count." (let ((term (ghostel--new 5 40 1000))) - ;; Push 7 rows into scrollback so the viewport shows rows 8..12 plus - ;; the trailing cursor row. + ;; Push 12 rows so the viewport shows rows 8..12 plus a trailing + ;; cursor row. (dotimes (i 12) (ghostel--write-input term (format "row-%02d\r\n" i))) (ghostel--write-input term "tail") @@ -394,22 +425,23 @@ and the helper sends arrows that move the cursor off the input." (evil-ghostel-mode 1) (let ((inhibit-read-only t)) (ghostel--redraw term t)) - ;; Terminal cursor is on the last viewport row; move point to the - ;; first viewport row (one row above the cursor). + ;; Terminal cursor is on the last viewport row; target a + ;; buffer position on the previous viewport row, same column. (let* ((tpos ghostel--cursor-pos) (trow (cdr tpos)) (target-viewport-row (1- trow)) (scrollback (max 0 (- (count-lines (point-min) (point-max)) - ghostel--term-rows)))) - (goto-char (point-min)) - (forward-line (+ scrollback target-viewport-row)) - (move-to-column (car tpos)) + ghostel--term-rows))) + (target-pos (save-excursion + (goto-char (point-min)) + (forward-line (+ scrollback target-viewport-row)) + (move-to-column (car tpos)) + (point)))) (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-ghostel--cursor-to-point)) - ;; Exactly one UP, no horizontal motion (cols match). + (evil-ghostel-goto-input-position target-pos)) (should (= 1 (length keys-sent))) (should (equal "up" (car keys-sent)))))))) @@ -465,21 +497,23 @@ redrawing elsewhere." ;; Test: advice fires on evil-insert / evil-append ;; ----------------------------------------------------------------------- -(ert-deftest evil-ghostel-test-advice-on-insert () - "Test that `evil-ghostel--before-insert' fires on `evil-insert'." +(ert-deftest evil-ghostel-test-insert-drives-shell-cursor () + "`evil-ghostel-insert' drives the shell cursor to point via arrow keys. +The command calls `evil-ghostel-goto-input-position' which moves the +terminal cursor to point's buffer position." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(0 . 0))) (evil-normal-state) (let ((sync-called nil)) - (cl-letf (((symbol-function 'evil-ghostel--cursor-to-point) - (lambda () (setq sync-called t)))) - (evil-insert 1)) + (cl-letf (((symbol-function 'evil-ghostel-goto-input-position) + (lambda (&rest _) (setq sync-called t)))) + (evil-ghostel-insert)) (should sync-called))))) -(ert-deftest evil-ghostel-test-advice-on-append () - "Test that `evil-ghostel--before-append' fires on `evil-append'." +(ert-deftest evil-ghostel-test-append-drives-shell-cursor () + "`evil-ghostel-append' drives the shell cursor to point via arrow keys." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello") @@ -489,101 +523,202 @@ redrawing elsewhere." (goto-char (point-min)) (move-to-column 2) (let ((sync-called nil)) - (cl-letf (((symbol-function 'evil-ghostel--cursor-to-point) - (lambda () (setq sync-called t)))) - (evil-append 1)) + (cl-letf (((symbol-function 'evil-ghostel-goto-input-position) + (lambda (&rest _) (setq sync-called t)))) + (evil-ghostel-append)) (should sync-called))))) -(ert-deftest evil-ghostel-test-advice-insert-line-sends-home () - "Test that `evil-insert-line' sends C-a and inhibits hook sync." +(ert-deftest evil-ghostel-test-append-at-cursor-does-not-advance () + "Regression: `evil-ghostel-append' at the terminal cursor does not forward-char. +Reproduces noctuid's report: with zsh-autosuggestions / RPROMPT +painting cells past the typed input, vanilla `evil-append' would +`forward-char' onto a non-input padding cell so the visual cursor +lands one cell past `d' while the PTY cursor (and backspace target) +stays on `d'. The guard skips the +1 step when point is at or past +`ghostel-cursor-point' and the cell at the cursor is blank/eol." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) + ;; Simulate RPROMPT padding: typed "word" + 10 padding cells + + ;; faux right-prompt content. Terminal cursor sits at the end of + ;; the typed input (pos 5), not at end of line. + (let ((inhibit-read-only t)) + (insert "word") + (insert (make-string 10 ?\s)) + (insert "rprompt")) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(0 . 0))) + ;; Isolate target-computation from PTY mechanics: simulate + ;; goto-input-position's net effect on point. + ((symbol-function 'evil-ghostel-goto-input-position) + (lambda (pos &rest _) (goto-char pos) t)) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore) + (ghostel--cursor-pos '(4 . 0)) + (ghostel--cursor-char-pos 5)) (evil-normal-state) - (let ((keys-sent '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) - (push key keys-sent)))) - (evil-insert-line 1)) - (should (member "a" keys-sent)) - ;; Hook should NOT have sent additional arrow keys - (should-not (member "left" keys-sent)) - (should-not (member "right" keys-sent)))))) - -(ert-deftest evil-ghostel-test-advice-append-line-sends-end () - "Test that `evil-append-line' sends C-e and inhibits hook sync." + (goto-char 5) ; point AT cursor-char-pos (end of typed input) + (evil-ghostel-append) + ;; Without the guard, target would be pos 6 (onto a padding space). + ;; The guard keeps target = point so the cursor stays put. + (should (= 5 (point))) + (should (eq 'insert evil-state))))) + +(ert-deftest evil-ghostel-test-append-after-cursor-moved-mid-input-advances () + "Regression: after the insert-state-entry hook moved the terminal cursor +mid-input (typical of `i' then `' then `a'), pressing `a' must +still advance one char. The padding-cell guard correctly falls +through when the cell at the cursor is non-blank typed text." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) + ;; Buffer: "hi" (the user typed `hi'). Then they pressed `i', and + ;; the insert-state-entry hook moved the terminal cursor from pos 3 + ;; (end of input) back to pos 2 (on `i'). Now they press `' + ;; then `a' — the cursor is at pos 2, the same as point. + (insert "hi") (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(0 . 0))) + ((symbol-function 'evil-ghostel-goto-input-position) + (lambda (pos &rest _) (goto-char pos) t)) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore) + ;; Cursor moved to mid-input by a previous sync. + (ghostel--cursor-pos '(1 . 0)) + (ghostel--cursor-char-pos 2)) (evil-normal-state) - (let ((keys-sent '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) - (push key keys-sent)))) - (evil-append-line 1)) - (should (member "e" keys-sent)) - ;; Hook should NOT have sent additional arrow keys - (should-not (member "left" keys-sent)) - (should-not (member "right" keys-sent)))))) - -(ert-deftest evil-ghostel-test-insert-line-multiline-syncs-row () - "Regression: `I' on a different row must sync the terminal cursor first. -Without the row sync, the Ctrl-a sent by the advice operates on the -last input line (where the terminal cursor was parked), not on the -line the user navigated to with `kk'." + (goto-char 2) ; point on `i', same as cursor + (evil-ghostel-append) + ;; Cell at cursor (pos 2, "i") is non-blank typed text → the + ;; guard falls through; target = (1+ point) = 3. + (should (= 3 (point))) + (should (eq 'insert evil-state))))) + +(ert-deftest evil-ghostel-test-insert-on-rprompt-clamps-to-row-end () + "Regression: `evil-ghostel-insert' on a padding/RPROMPT cell clamps target. +Symmetric to `evil-ghostel-test-append-at-cursor-does-not-advance': +when point sits past typed input (in the padding gap or on +RPROMPT cells), `i' must drive the cursor to row-end rather than +the raw point — otherwise N right-arrows are sent, the shell +clamps them silently, and Emacs `point' ends up past the live +cursor." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert "line one\nline two\nline three") - ;; Terminal cursor at end of line three (row 2); point on row 0. - (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(10 . 2))) + (let ((inhibit-read-only t)) + (insert "word") + (insert (make-string 10 ?\s)) + (insert "rprompt")) + (cl-letf* ((target-pos nil) + ((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ((symbol-function 'evil-ghostel-goto-input-position) + (lambda (pos &rest _) (setq target-pos pos) (goto-char pos) t)) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore) + (ghostel--cursor-pos '(4 . 0)) + (ghostel--cursor-char-pos 5)) (evil-normal-state) - (goto-char (point-min)) - (let ((keys-sent '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key mods &rest _) - (push (cons key mods) keys-sent)))) - (evil-insert-line 1)) - ;; Two `up' arrows precede the Ctrl-a so the shell's readline - ;; cursor lands on the right input row before going to bol. - (should (= 2 (cl-count '("up" . "") keys-sent :test #'equal))) - (should (cl-find '("a" . "ctrl") keys-sent :test #'equal)))))) - -(ert-deftest evil-ghostel-test-append-line-multiline-syncs-row () - "Regression: `A' on a different row must sync the terminal cursor first. -Symmetric to the `I' multi-row case — without the row sync the Ctrl-e -goes to the end of the last input line." + (goto-char 12) ; point inside the padding gap + (evil-ghostel-insert) + ;; Target clamped to row-end (= 5 — end of "word"), NOT raw point (12). + (should (= 5 target-pos)) + (should (eq 'insert evil-state))))) + +(ert-deftest evil-ghostel-test-append-before-cursor-uses-vanilla () + "Append mid-input advances by one cell. +Point inside the input region but before the terminal cursor must +still advance by one cell (vim semantics)." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert "line one\nline two\nline three") + (insert "hello world") (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(10 . 2))) + ((symbol-function 'evil-ghostel-goto-input-position) + (lambda (pos &rest _) (goto-char pos) t)) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore) + (ghostel--cursor-pos '(11 . 0)) + (ghostel--cursor-char-pos 12)) (evil-normal-state) - (goto-char (point-min)) - (let ((keys-sent '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key mods &rest _) - (push (cons key mods) keys-sent)))) - (evil-append-line 1)) - (should (= 2 (cl-count '("up" . "") keys-sent :test #'equal))) - (should (cl-find '("e" . "ctrl") keys-sent :test #'equal)))))) - -(ert-deftest evil-ghostel-test-change-eol-syncs-cursor-to-point () - "Regression: `C' at eol of a non-cursor row must sync before insert. -With point at end of line one and the terminal cursor at end of line -three, `C' produces an empty range (count = 0). Without an explicit -sync after `delete-region', insert state would inherit the terminal -cursor from line three and the user's typed characters would land on -the last input line — what was reported as `C deletes the last line'." + (goto-char 3) ; point on 'e' of "hello" + (evil-ghostel-append) + ;; Target = (min (1+ point) row-end) = 4. + (should (= 4 (point))) + (should (eq 'insert evil-state))))) + +(ert-deftest evil-ghostel-test-insert-line-sends-arrows-to-input-start () + "`evil-ghostel-insert-line' drives the shell cursor to input-start via arrows. +The vterm-style shape uses `evil-ghostel-goto-input-position' rather +than sending readline's C-a — deterministic regardless of the shell's +`bindkey -v' / vi-mode key bindings, which is what Bug B (issue #264) +was exposing." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (evil-normal-state) + (let ((keys-sent '())) + (cl-letf (((symbol-function 'ghostel--send-encoded) + (lambda (key mods &rest _) + (push (cons key mods) keys-sent))) + ((symbol-function 'evil-ghostel--sync-render) #'ignore) + ;; Mock the entry hook to avoid double-counting arrows: in + ;; tests `sync-render' is a no-op so `ghostel--cursor-pos' + ;; doesn't track the move, and the hook's idempotent re-run + ;; would otherwise re-send the same arrows. + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore)) + (evil-ghostel-insert-line)) + ;; Cursor at col 7 (end of "hello"), input-start at col 2 → 5 lefts. + (should (= 5 (cl-count '("left" . "") keys-sent :test #'equal))) + ;; Critically: no readline C-a — Bug B (#264) determinism. + (should-not (cl-find '("a" . "ctrl") keys-sent :test #'equal)) + (should (evil-insert-state-p))))) + +(ert-deftest evil-ghostel-test-append-line-sends-arrows-to-row-end () + "`evil-ghostel-append-line' drives the shell cursor to row-end via arrows. +Same vterm-style shape as `I' — no readline C-e, deterministic +regardless of shell vi-mode." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (evil-normal-state) + (goto-char 3) ; point at input-start ("h" of "hello"); cursor at 8 (eol). + (let ((keys-sent '())) + (cl-letf (((symbol-function 'ghostel--send-encoded) + (lambda (key mods &rest _) + (push (cons key mods) keys-sent))) + ((symbol-function 'evil-ghostel--sync-render) #'ignore) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore)) + (evil-ghostel-append-line)) + ;; Cursor at col 7, row-end at col 7 (after "hello", no padding) → + ;; goto-input-position with target == cursor-pos is a no-op horizontally. + (should-not (cl-find '("e" . "ctrl") keys-sent :test #'equal)) + (should (evil-insert-state-p))))) + +(ert-deftest evil-ghostel-test-insert-line-pins-point-at-input-start () + "Regression for Bug A (#264): `I' lands point at `ghostel-input-start-point'. +After the vterm-style rewrite point is set deterministically before +`evil-insert-state' runs — no async redraw can drag point past the +right prompt (Bug A's \"until first keystroke\" symptom)." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (evil-normal-state) + (cl-letf (((symbol-function 'ghostel--send-encoded) #'ignore) + ((symbol-function 'evil-ghostel--sync-render) #'ignore) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore)) + (evil-ghostel-insert-line)) + (should (= 3 (point))) ; right after "$ " + (should (evil-insert-state-p)))) + +(ert-deftest evil-ghostel-test-append-line-pins-point-at-row-end () + "Regression for Bug A (#264): `A' lands point at end of typed input." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (evil-normal-state) + (goto-char (point-min)) + (cl-letf (((symbol-function 'ghostel--send-encoded) #'ignore) + ((symbol-function 'evil-ghostel--sync-render) #'ignore) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore)) + (evil-ghostel-append-line)) + (should (= 8 (point))) ; end of "hello" + (should (evil-insert-state-p)))) + +(ert-deftest evil-ghostel-test-change-eol-snaps-point-to-cursor () + "`C' at eol of a non-cursor row enters insert state at the live cursor. +Off the cursor row there's no PTY-routed editing to be done — the +delete is a no-op on the scrollback line, then `evil-ghostel-insert' +takes the off-row branch and the entry hook's `reset-cursor-point' +pulls point onto the live cursor's row. No history-navigation `up' +arrows are sent (which the old `sync-inhibit' path mistakenly did)." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "line one\nline two\nline three") (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(10 . 2))) (evil-normal-state) - ;; Point at end of line one (just before the first newline). (goto-char (point-min)) (end-of-line) (let ((keys-sent '()) @@ -591,25 +726,22 @@ the last input line — what was reported as `C deletes the last line'." (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key mods &rest _) (push (cons key mods) keys-sent)))) - ;; `C' at eol → evil-change with empty range. - (evil-change eol-pos eol-pos 'inclusive nil nil - #'evil-delete-line)) - ;; The post-delete cursor-to-point must emit two `up' arrows so - ;; the terminal cursor lands on point's row before insert state. - (should (= 2 (cl-count '("up" . "") keys-sent :test #'equal))))))) + (evil-ghostel-change-line eol-pos eol-pos 'inclusive nil nil)) + (should-not (cl-find '("up" . "") keys-sent :test #'equal)) + (should (evil-insert-state-p)))))) ;; ----------------------------------------------------------------------- -;; Test: advice is no-op outside ghostel buffers +;; Test: insert-state-entry hook is a no-op outside ghostel buffers ;; ----------------------------------------------------------------------- -(ert-deftest evil-ghostel-test-advice-no-op-outside-ghostel () - "Test that advice does nothing when `evil-ghostel-mode' is nil." +(ert-deftest evil-ghostel-test-insert-state-entry-no-op-outside-ghostel () + "Insert-state-entry hook is buffer-local: nothing fires in unrelated buffers." (with-temp-buffer (evil-local-mode 1) (evil-normal-state) (let ((sync-called nil)) - (cl-letf (((symbol-function 'evil-ghostel--cursor-to-point) - (lambda () (setq sync-called t)))) + (cl-letf (((symbol-function 'evil-ghostel-goto-input-position) + (lambda (&rest _) (setq sync-called t)))) (evil-insert 1)) (should-not sync-called)))) @@ -633,51 +765,379 @@ the last input line — what was reported as `C deletes the last line'." ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-delete-region () - "Test that `evil-ghostel--delete-region' sends correct keys." + "End-to-end: `evil-ghostel-delete-input-region' sends the expected keys." (evil-ghostel-test--with-buffer 5 40 "$ echo hello" ;; Delete "hello" (col 7-12) (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-ghostel--delete-region 8 13)) + (evil-ghostel-delete-input-region 8 13)) ;; Should send arrow keys to move cursor, then 5 backspaces (should (= 5 (cl-count "backspace" keys-sent :test #'equal)))))) ;; ----------------------------------------------------------------------- -;; Test: meaningful-length helper (render padding stripping) +;; Test: meaningful-input-length helper (render padding stripping) ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-meaningful-length-strips-trailing () "Trailing whitespace counts only when TEXT spans multiple lines. Single-line `\"word \"' is real user content (e.g. `dw' over a word plus its trailing space); multi-line ranges may contain TUI box -padding that should be stripped per line." - (should (= 0 (evil-ghostel--meaningful-length ""))) - (should (= 3 (evil-ghostel--meaningful-length "AAA"))) +padding that should be stripped per line. + +The implementation lives in `evil-ghostel--meaningful-input-length'." + (should (= 0 (evil-ghostel--meaningful-input-length ""))) + (should (= 3 (evil-ghostel--meaningful-input-length "AAA"))) ;; Single-line: trailing whitespace is preserved (real content). - (should (= 9 (evil-ghostel--meaningful-length "AAA "))) - (should (= 5 (evil-ghostel--meaningful-length "word "))) + (should (= 9 (evil-ghostel--meaningful-input-length "AAA "))) + (should (= 5 (evil-ghostel--meaningful-input-length "word "))) ;; Multi-line: per-line trailing whitespace stripped (TUI padding). - (should (= 7 (evil-ghostel--meaningful-length "AAA \nBBB "))) - (should (= 4 (evil-ghostel--meaningful-length "AAA \n"))) + (should (= 7 (evil-ghostel--meaningful-input-length "AAA \nBBB "))) + (should (= 4 (evil-ghostel--meaningful-input-length "AAA \n"))) ;; Inner whitespace preserved either way. - (should (= 7 (evil-ghostel--meaningful-length "A B C "))) - (should (= 8 (evil-ghostel--meaningful-length "A B C D")))) + (should (= 7 (evil-ghostel--meaningful-input-length "A B C "))) + (should (= 8 (evil-ghostel--meaningful-input-length "A B C D")))) + +;; ----------------------------------------------------------------------- +;; Test: input-region helpers (cursor-row-end, point-in-input, clamp) +;; ----------------------------------------------------------------------- + +(defmacro evil-ghostel-test--with-input-fixture (prompt input &rest body) + "Set up a mock terminal buffer with PROMPT (carrying `ghostel-prompt') +followed by INPUT, with `ghostel--cursor-char-pos' positioned at the +end of INPUT. Runs BODY in the buffer. + +Evil and `evil-ghostel-mode' are enabled so tests can invoke evil +commands. Mocks the terminal handle and viewport so the +input-region helpers can derive prompt boundaries and viewport rows +without a real native module." + (declare (indent 2)) + `(let ((buf (generate-new-buffer " *evil-ghostel-test-input*"))) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (let ((inhibit-read-only t)) + (insert (propertize ,prompt 'ghostel-prompt t)) + (insert ,input)) + (setq ghostel--term 'fake) + (setq ghostel--term-rows 1) + (setq ghostel--cursor-char-pos (point)) + (setq ghostel--cursor-pos (cons (current-column) 0)) + (evil-local-mode 1) + (evil-ghostel-mode 1) + (cl-letf (((symbol-function 'ghostel--mode-enabled) + (lambda (&rest _) nil))) + ,@body)) + (kill-buffer buf)))) + +(ert-deftest evil-ghostel-test-cursor-row-end-point-returns-eol () + "`evil-ghostel--cursor-row-end-point' is end-of-line at the cursor's row." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (should (= (point-max) (evil-ghostel--cursor-row-end-point))))) + +(ert-deftest evil-ghostel-test-cursor-row-end-point-respects-input-property () + "OSC 133;B `ghostel-input' cells win over the gap heuristic. +A row painted with bash/zsh shell integration carries `ghostel-input' +on every input cell; the helper returns the position right after the +rightmost such cell, regardless of trailing renderer cells." + (let ((buf (generate-new-buffer " *evil-ghostel-test-input-prop*"))) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + ;; OSC 133;B span over the typed input. + (insert (propertize "hello" 'ghostel-input t)) + ;; Padding + autosuggest hint past the input (no input prop). + (insert " hint")) + (setq ghostel--term 'fake) + (setq ghostel--term-rows 1) + ;; Cursor at end of typed input (between "hello" and the padding). + (setq ghostel--cursor-char-pos 8) ; just after "hello" + (setq ghostel--cursor-pos '(7 . 0)) + ;; End-of-input is right after the last `ghostel-input' cell. + (should (= 8 (evil-ghostel--cursor-row-end-point)))) + (kill-buffer buf)))) + +(ert-deftest evil-ghostel-test-cursor-row-end-point-uses-first-input-region () + "Issue #264 fish repro: libghostty tags BOTH typed input cells AND +right-prompt cells as SEMANTIC_INPUT (the latter via its cell- +positioning heuristic when fish jumps the cursor to draw the right +prompt). Two disjoint `ghostel-input' regions separated by the +padding gap. The helper must return the end of the *first* region +\(typed input), not the rightmost cell (inside the right prompt)." + (let ((buf (generate-new-buffer " *evil-ghostel-test-fish-rprompt*"))) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert (propertize "foo bar" 'ghostel-input t)) + (insert (make-string 20 ?\s)) + (insert (propertize "main *" 'ghostel-input t))) + (setq ghostel--term 'fake) + (setq ghostel--term-rows 1) + ;; Cursor at end of typed "foo bar" (col 9, pos 10). + (setq ghostel--cursor-char-pos 10) + (setq ghostel--cursor-pos '(9 . 0)) + ;; First `ghostel-input' region ends at pos 10 (after "foo bar"). + ;; Rightmost region (the right prompt) is ignored. + (should (= 10 (evil-ghostel--cursor-row-end-point)))) + (kill-buffer buf)))) + +(ert-deftest evil-ghostel-test-cursor-row-end-point-clamps-at-right-prompt-gap () + "Bug A (#264): fish-style right prompt is excluded by the gap heuristic. +With no `ghostel-input' property on the row (fish without OSC 133;B), +a whitespace gap of `evil-ghostel-right-prompt-gap' or more columns +between typed input and right-aligned content marks the boundary." + (let ((buf (generate-new-buffer " *evil-ghostel-test-rprompt*"))) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "nslookup") + (insert (make-string 20 ?\s)) ; >> gap threshold + (insert "main *")) + (setq ghostel--term 'fake) + (setq ghostel--term-rows 1) + ;; Cursor at end of typed "nslookup" (col 10, pos 11). + (setq ghostel--cursor-char-pos 11) + (setq ghostel--cursor-pos '(10 . 0)) + ;; End-of-input is pos 11 (just after "nslookup"), NOT the + ;; position after "main *" — the gap excludes the right prompt. + (should (= 11 (evil-ghostel--cursor-row-end-point)))) + (kill-buffer buf)))) + +(ert-deftest evil-ghostel-test-cursor-row-end-point-tight-gap-keeps-input () + "Normal input with double-space (gap < threshold) stays whole. +A `cmd arg' pattern (2 spaces between tokens) must not trigger the +right-prompt heuristic — input includes both words and the spaces." + (let ((buf (generate-new-buffer " *evil-ghostel-test-tight*"))) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "cmd arg")) ; 2-space gap, < threshold (6) + (setq ghostel--term 'fake) + (setq ghostel--term-rows 1) + (setq ghostel--cursor-char-pos 11) ; just after "arg" + (setq ghostel--cursor-pos '(10 . 0)) + (should (= 11 (evil-ghostel--cursor-row-end-point)))) + (kill-buffer buf)))) + +(ert-deftest evil-ghostel-test-end-of-line-clamps-past-right-prompt () + "Bug A (#264) end-to-end: `$' lands at end of input, not on the right prompt." + (let ((buf (generate-new-buffer " *evil-ghostel-test-end-of-line-rprompt*"))) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "cmd") + (insert (make-string 20 ?\s)) + (insert "branch *")) + (setq ghostel--term 'fake) + (setq ghostel--term-rows 1) + (setq ghostel--cursor-char-pos 6) ; just after "cmd" + (setq ghostel--cursor-pos '(5 . 0)) + (evil-local-mode 1) + (evil-ghostel-mode 1) + (cl-letf (((symbol-function 'ghostel--mode-enabled) + (lambda (&rest _) nil))) + (evil-normal-state) + (goto-char 3) ; on first input char + (evil-ghostel-end-of-line 1)) + ;; Clamped to end-of-input (after "cmd" = pos 6), NOT into + ;; "branch *" past the 20-space gap. Without the right-prompt + ;; clamp `$' would have landed somewhere inside "branch *". + (should (= 6 (point)))) + (kill-buffer buf)))) + +(ert-deftest evil-ghostel-test-point-in-input-p-true-between-prompt-and-eol () + "Returns t when point is on the cursor row between input-start and EOL." + (evil-ghostel-test--with-input-fixture "$ " "hello" + ;; Right after the prompt — first char of input. + (should (evil-ghostel-point-in-input-p 3)) + ;; At the cursor itself. + (should (evil-ghostel-point-in-input-p ghostel--cursor-char-pos)))) + +(ert-deftest evil-ghostel-test-point-in-input-p-false-on-prompt-char () + "Returns nil when POS is inside the prompt prefix." + (evil-ghostel-test--with-input-fixture "$ " "hello" + ;; Position 1 ($ char) and 2 (space) are part of the prompt. + (should-not (evil-ghostel-point-in-input-p 1)) + (should-not (evil-ghostel-point-in-input-p 2)))) + +(ert-deftest evil-ghostel-test-clamp-to-input-narrows-on-cursor-row () + "A range with endpoints inside the prompt is clamped to the input region." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (let ((clamped (evil-ghostel--clamp-to-input + (cons 1 ghostel--cursor-char-pos)))) + ;; BEG was in the prompt (1) → bumped to input-start (3). + (should (= 3 (car clamped))) + ;; END was at the cursor → unchanged. + (should (= ghostel--cursor-char-pos (cdr clamped)))))) + +(ert-deftest evil-ghostel-test-clamp-to-input-trims-end-past-cursor () + "A range whose END walks past the live cursor is trimmed back." + (evil-ghostel-test--with-input-fixture "$ " "hello" + ;; Pretend the renderer wrote some padding after the cursor (TUI box). + (let ((inhibit-read-only t)) + (save-excursion (insert " "))) + (let* ((past-cursor (+ ghostel--cursor-char-pos 3)) + (clamped (evil-ghostel--clamp-to-input (cons 3 past-cursor)))) + (should (= 3 (car clamped))) + (should (= ghostel--cursor-char-pos (cdr clamped)))))) + +(ert-deftest evil-ghostel-test-clamp-to-input-passes-through-off-row () + "Ranges that touch a non-cursor row are returned unchanged." + (let ((buf (generate-new-buffer " *evil-ghostel-test-clamp-off-row*"))) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (let ((inhibit-read-only t)) + (insert "scrollback\n") + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "input")) + (setq ghostel--term 'fake) + (setq ghostel--term-rows 2) + (setq ghostel--cursor-char-pos (point)) + (setq ghostel--cursor-pos (cons (current-column) 1)) + ;; Range spans first row (off cursor row) and cursor row. + (let ((input (cons 1 ghostel--cursor-char-pos))) + (should (equal input (evil-ghostel--clamp-to-input input))))) + (kill-buffer buf)))) + +(ert-deftest evil-ghostel-test-goto-input-position-sends-arrows-unit () + "Unit: |dx| left arrows are sent when point is left of the cursor." + (evil-ghostel-test--with-input-fixture "$ " "hello world" + ;; cursor-pos col 13 (after "$ hello world"); target is col 7 (start + ;; of "hello"), so 6 LEFT arrows. + (let ((keys-sent '())) + (cl-letf (((symbol-function 'ghostel--send-encoded) + (lambda (key _mods &rest _) + (push key keys-sent)))) + (evil-ghostel-goto-input-position 8)) ; pos 8 = column 7 + (should (= 6 (length keys-sent))) + (should (cl-every (lambda (k) (equal k "left")) keys-sent))))) + +(ert-deftest evil-ghostel-test-goto-input-position-no-op-at-target () + "No keys are sent when point already matches the terminal cursor." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (let ((keys-sent '())) + (cl-letf (((symbol-function 'ghostel--send-encoded) + (lambda (key _mods &rest _) + (push key keys-sent)))) + (evil-ghostel-goto-input-position ghostel--cursor-char-pos)) + (should (zerop (length keys-sent)))))) + +(ert-deftest evil-ghostel-test-sync-render-forces-deferred-redraw () + "`sync-render' force-runs `ghostel--delayed-redraw' after a bulk-output drain. +The filter only takes the synchronous redraw path for small echoes +arriving within `ghostel-immediate-redraw-interval' of the last +keystroke. Larger echoes (e.g. `cc' sending 100 backspaces) take +the bulk-output branch, which queues a timer-driven redraw — so +`ghostel--cursor-pos' / `ghostel--cursor-char-pos' are stale until +the timer fires. `sync-render' must close the gap by forcing the +deferred redraw before returning, otherwise the next operator +(e.g. `i' after `cc') reads stale cursor state and computes a +wrong arrow delta." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (let* ((redraw-calls 0) + ;; Pretend the filter deferred a redraw to its timer. + (fake-timer (run-with-timer 999 nil #'ignore)) + (ghostel--redraw-timer fake-timer) + (ghostel--pending-output (list "x")) + (ghostel--process 'fake-proc)) + (unwind-protect + (cl-letf (((symbol-function 'process-live-p) (lambda (_) t)) + ((symbol-function 'accept-process-output) + (lambda (&rest _) nil)) + ((symbol-function 'ghostel--delayed-redraw) + (lambda (_buf) (cl-incf redraw-calls)))) + (evil-ghostel--sync-render) + (should (= 1 redraw-calls)) + (should (null ghostel--redraw-timer))) + (when (timerp fake-timer) (cancel-timer fake-timer)))))) + +(ert-deftest evil-ghostel-test-sync-render-no-op-when-nothing-deferred () + "`sync-render' does NOT force a redraw when the filter handled the echo. +Small interactive echoes are drawn synchronously inside +`ghostel--filter''s immediate-redraw branch, which clears both +`ghostel--pending-output' and `ghostel--redraw-timer'. In that +state `sync-render' must not call `ghostel--delayed-redraw' a +second time — the cursor state is already current." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (let ((redraw-calls 0) + (ghostel--redraw-timer nil) + (ghostel--pending-output nil) + (ghostel--process 'fake-proc)) + (cl-letf (((symbol-function 'process-live-p) (lambda (_) t)) + ((symbol-function 'accept-process-output) + (lambda (&rest _) nil)) + ((symbol-function 'ghostel--delayed-redraw) + (lambda (_buf) (cl-incf redraw-calls)))) + (evil-ghostel--sync-render) + (should (zerop redraw-calls)))))) + +(ert-deftest evil-ghostel-test-sync-render-drain-loop-respects-cap () + "`sync-render' caps the drain loop at `*-max-iterations'. +A runaway shell that returns non-nil from every +`accept-process-output' call must not hang the caller. The cap +bounds total wait at ~max-iter × 50 ms." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (let ((accept-calls 0) + (evil-ghostel-sync-render-max-iterations 5) + (ghostel--redraw-timer nil) + (ghostel--pending-output nil) + (ghostel--process 'fake-proc)) + (cl-letf (((symbol-function 'process-live-p) (lambda (_) t)) + ((symbol-function 'accept-process-output) + (lambda (&rest _) (cl-incf accept-calls) t)) + ((symbol-function 'ghostel--delayed-redraw) #'ignore)) + (evil-ghostel--sync-render) + ;; Loop exits via the iteration cap, not via accept returning nil. + (should (= 5 accept-calls)))))) + +(ert-deftest evil-ghostel-test-delete-input-region-sends-backspaces () + "`evil-ghostel-delete-input-region' sends one backspace per meaningful char." + (evil-ghostel-test--with-input-fixture "$ " "hello" + (let ((keys-sent '())) + (cl-letf (((symbol-function 'ghostel--send-encoded) + (lambda (key _mods &rest _) + (push key keys-sent)))) + (evil-ghostel-delete-input-region 3 ghostel--cursor-char-pos)) + (should (= 5 (cl-count "backspace" keys-sent :test #'equal)))))) + +(ert-deftest evil-ghostel-test-replace-input-region-deletes-then-pastes () + "`evil-ghostel-replace-input-region' first deletes, then pastes new text." + (evil-ghostel-test--with-input-fixture "$ " "abc" + (let ((pasted nil) + (keys-sent '())) + (cl-letf (((symbol-function 'ghostel--send-encoded) + (lambda (key _mods &rest _) (push key keys-sent))) + ((symbol-function 'ghostel--paste-text) + (lambda (text) (setq pasted text)))) + (evil-ghostel-replace-input-region 3 ghostel--cursor-char-pos "XYZ")) + (should (= 3 (cl-count "backspace" keys-sent :test #'equal))) + (should (equal "XYZ" pasted))))) ;; ----------------------------------------------------------------------- ;; Test: evil-delete advice ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-delete-sends-backspace-keys () - "Test that `evil-delete' advice sends backspace keys via PTY." + "`evil-ghostel-delete' sends backspace keys via the PTY." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello world") (goto-char (point-min)) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(0 . 0)) - ((symbol-function 'evil-ghostel--cursor-to-point) #'ignore)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore)) (evil-normal-state) (let ((bs-count 0)) (cl-letf (((symbol-function 'ghostel--send-encoded) @@ -685,62 +1145,72 @@ padding that should be stripped per line." (when (equal key "backspace") (cl-incf bs-count))))) ;; Delete 5 chars (simulates dw on "hello") - (evil-delete 1 6 'inclusive nil nil)) + (evil-ghostel-delete 1 6 'inclusive nil nil)) (should (= 5 bs-count)))))) -(ert-deftest evil-ghostel-test-delete-line-same-row-uses-ctrl-u () - "Test that `dd' on the cursor's own line uses the Ctrl-e/Ctrl-u shortcut. -Single-line shell case: the buffer line includes the prompt prefix, -so backspacing through the buffer text would hit the prompt boundary -and silently no-op. Readline's Ctrl-u clears just the input area. -See issue #218 for the multi-line counterpart." +(ert-deftest evil-ghostel-test-delete-line-same-row-uses-backspaces () + "`dd' on the cursor's own line routes through `delete-input-region'. +vterm-collection's shape: same code path as every other delete. +The clamped range is [input-start, row-end], so the backspace count +equals the typed input length (5 for `hello'); no readline C-e/C-u +shortcut is invoked." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert "$ hello") - (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - ;; Terminal cursor on the same row as point. - (ghostel--cursor-pos '(7 . 0))) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "hello")) + (setq-local ghostel--cursor-pos '(7 . 0)) + (setq-local ghostel--cursor-char-pos 8) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil))) (evil-normal-state) (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key mods &rest _) - (push (cons key mods) keys-sent)))) - (evil-delete (line-beginning-position) (line-end-position) 'line nil nil)) - (should (cl-find '("e" . "ctrl") keys-sent :test #'equal)) - (should (cl-find '("u" . "ctrl") keys-sent :test #'equal)) - (should-not (cl-find '("backspace" . "") keys-sent :test #'equal)) - ;; Flag set so the next redraw snaps point to the cursor's new - ;; position (start of input area) instead of leaving point on the - ;; prompt at column 0. - (should evil-ghostel--sync-point-on-next-redraw))))) - -(ert-deftest evil-ghostel-test-change-line-same-row-uses-ctrl-u () - "Test that `cc' on the cursor's own line uses Ctrl-e/Ctrl-u then enters insert. -Same single-line shell rationale as `dd' — see -`evil-ghostel-test-delete-line-same-row-uses-ctrl-u'." + (push (cons key mods) keys-sent))) + ((symbol-function 'evil-ghostel--sync-render) #'ignore)) + (evil-ghostel-delete (line-beginning-position) (line-end-position) + 'line nil nil)) + ;; 5 backspaces — one per char of "hello". + (should (= 5 (cl-count '("backspace" . "") keys-sent :test #'equal))) + ;; No readline shortcuts. + (should-not (cl-find '("e" . "ctrl") keys-sent :test #'equal)) + (should-not (cl-find '("u" . "ctrl") keys-sent :test #'equal)))))) + +(ert-deftest evil-ghostel-test-change-line-same-row-uses-backspaces () + "`cc' on the cursor's own line routes through `delete-input-region' +then enters insert state. Same vterm-style shape as `dd'." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert "$ hello") - (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(7 . 0))) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "hello")) + (setq-local ghostel--cursor-pos '(7 . 0)) + (setq-local ghostel--cursor-char-pos 8) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil))) (evil-normal-state) (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key mods &rest _) - (push (cons key mods) keys-sent)))) - (evil-change (line-beginning-position) (line-end-position) - 'line nil nil nil)) - (should (cl-find '("e" . "ctrl") keys-sent :test #'equal)) - (should (cl-find '("u" . "ctrl") keys-sent :test #'equal)) - (should-not (cl-find '("backspace" . "") keys-sent :test #'equal)) + (push (cons key mods) keys-sent))) + ((symbol-function 'evil-ghostel--sync-render) #'ignore) + ((symbol-function 'evil-ghostel--insert-state-entry) #'ignore)) + (evil-ghostel-change (line-beginning-position) (line-end-position) + 'line nil nil)) + (should (= 5 (cl-count '("backspace" . "") keys-sent :test #'equal))) + (should-not (cl-find '("e" . "ctrl") keys-sent :test #'equal)) + (should-not (cl-find '("u" . "ctrl") keys-sent :test #'equal)) + ;; Bug fix: point lands at input-start (pos 3, just after "$ "), + ;; NOT at column 0 of the buffer line. + (should (= 3 (point))) (should (eq evil-state 'insert)))))) (ert-deftest evil-ghostel-test-delete-line-multiline-syncs-cursor () - "Regression for #218: line-type delete must sync terminal cursor first. -With a multi-line input where the terminal cursor sits on the last line, -pressing dd on the first line must move the terminal cursor up to that -line before deleting — otherwise Ctrl+U / shortcut-style deletion would -target the line the cursor sat on (the last input line)." + "Regression for #218: line-type delete syncs terminal cursor first. +With a multi-line input where the terminal cursor sits on the last +line, pressing `dd' on the first line moves the terminal cursor up +to that line before deleting — otherwise Ctrl+U / shortcut-style +deletion would target the line the cursor sat on (the last input +line)." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "line one\nline two\nline three") @@ -754,7 +1224,7 @@ target the line the cursor sat on (the last input line)." (lambda (key mods &rest _) (push (cons key mods) keys-sent)))) ;; Line 1 spans positions 1..10 ("line one" + newline = 9 chars) - (evil-delete 1 10 'line nil nil)) + (evil-ghostel-delete 1 10 'line nil nil)) ;; Sync from row 2 to row 1 (end of deleted region = bol of line 2) (should (= 1 (cl-count '("up" . "") keys-sent :test #'equal))) ;; Sync from col 10 to col 0 @@ -764,13 +1234,14 @@ target the line the cursor sat on (the last input line)." (should-not (cl-find '("u" . "ctrl") keys-sent :test #'equal)))))) (ert-deftest evil-ghostel-test-delete-line-strips-render-padding () - "Regression for #218: multi-line `dd' must not backspace TUI box-padding. + "Regression for #218: multi-line `dd' does not backspace TUI box-padding. TUIs that draw a fixed-width input box (e.g. prompt_toolkit) write spaces past the user's input out to the box border; those land in the Emacs buffer but are not characters in the TUI's input model. -Backspace count must equal trimmed line length + newline. -Forces the multi-line backspace path by placing the terminal cursor -on a different row than point." +Backspace count equals trimmed line length + newline. + +Forces the multi-line backspace path by placing the terminal +cursor on a different row than point." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) ;; "AAA" + 77 box-padding spaces + newline + "BBB" + 77 box-padding spaces. @@ -779,7 +1250,7 @@ on a different row than point." (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) ;; Terminal cursor on row 1 (BBB); point will be on row 0 (AAA). (ghostel--cursor-pos '(0 . 1)) - ((symbol-function 'evil-ghostel--cursor-to-point) #'ignore)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore)) (evil-normal-state) (goto-char (point-min)) (let ((bs-count 0)) @@ -788,26 +1259,23 @@ on a different row than point." (when (equal key "backspace") (cl-incf bs-count))))) ;; Line 1 spans bol..bol-of-line-2 (81 chars including newline). - (evil-delete (point-min) (line-beginning-position 2) 'line nil nil)) + (evil-ghostel-delete (point-min) (line-beginning-position 2) + 'line nil nil)) ;; Trimmed: "AAA\n" = 4 backspaces, not 81. (should (= 4 bs-count)))))) (ert-deftest evil-ghostel-test-delete-char () - "Test that `evil-delete-char' (x) works without error. -Regression: yank-handler arg was not optional in advice signature, -so calls from `evil-delete-char' (which passes only 4 args to -`evil-delete') raised `wrong-number-of-arguments'." + "`evil-ghostel-delete-char' (x) routes through PTY and stays in normal." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello") (goto-char (point-min)) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(0 . 0)) - ((symbol-function 'evil-ghostel--cursor-to-point) #'ignore) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore) ((symbol-function 'ghostel--send-encoded) #'ignore)) (evil-normal-state) - ;; evil-delete-char calls evil-delete without yank-handler - (evil-delete-char 1 2 'exclusive nil) + (evil-ghostel-delete-char 1 2 'exclusive nil) (should (eq evil-state 'normal))))) ;; ----------------------------------------------------------------------- @@ -815,27 +1283,26 @@ so calls from `evil-delete-char' (which passes only 4 args to ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-change-deletes-and-inserts () - "Test that `evil-change' advice deletes via PTY and enters insert state." + "`evil-ghostel-change' deletes via PTY and enters insert state." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello world") (goto-char (point-min)) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(0 . 0)) - ((symbol-function 'evil-ghostel--cursor-to-point) #'ignore)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore)) (evil-normal-state) (let ((bs-count 0)) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (when (equal key "backspace") (cl-incf bs-count))))) - (evil-change 1 6 'inclusive nil nil nil)) + (evil-ghostel-change 1 6 'inclusive nil nil)) (should (= 5 bs-count)) (should (eq evil-state 'insert)))))) (ert-deftest evil-ghostel-test-change-whole-line () - "Test that `evil-change-whole-line' (cc/S) works without error. -Regression: delete-func arg was not optional in advice signature." + "`evil-ghostel-substitute-line' (cc/S) runs without error." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello world") @@ -844,8 +1311,7 @@ Regression: delete-func arg was not optional in advice signature." (ghostel--cursor-pos '(0 . 0)) ((symbol-function 'ghostel--send-encoded) #'ignore)) (evil-normal-state) - ;; evil-change-whole-line calls evil-change without delete-func - (evil-change-whole-line 1 12 nil nil) + (evil-ghostel-substitute-line 1 12 nil nil) (should (eq evil-state 'insert))))) ;; ----------------------------------------------------------------------- @@ -853,14 +1319,14 @@ Regression: delete-func arg was not optional in advice signature." ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-replace-deletes-and-inserts () - "Test that `evil-replace' deletes then inserts replacement text." + "`evil-ghostel-replace' deletes then inserts replacement text." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello") (goto-char (point-min)) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(0 . 0)) - ((symbol-function 'evil-ghostel--cursor-to-point) #'ignore)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore)) (evil-normal-state) (let ((bs-count 0) (pasted nil)) @@ -870,26 +1336,26 @@ Regression: delete-func arg was not optional in advice signature." (cl-incf bs-count)))) ((symbol-function 'ghostel--paste-text) (lambda (text) (setq pasted text)))) - (evil-replace 1 4 'inclusive ?X)) + (evil-ghostel-replace 1 4 'inclusive ?X)) (should (= 3 bs-count)) (should (equal "XXX" pasted)))))) (ert-deftest evil-ghostel-test-replace-counts-match-on-trailing-space () - "Regression: paste count and delete count must agree. -Both `evil-ghostel--delete-region' and the paste in -`evil-ghostel--around-replace' use `evil-ghostel--meaningful-length' -on the same substring, so the values must agree even when trailing -whitespace handling differs (multi-line ranges strip; single-line -ranges don't)." + "Regression: paste count and delete count agree on multi-line ranges. +Both `evil-ghostel-delete-input-region' and the paste in +`evil-ghostel-replace' use `evil-ghostel--meaningful-input-length' on +the same substring, so the values agree even when trailing +whitespace handling differs (multi-line ranges strip per-line +padding; single-line ranges don't)." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) ;; Multi-line range with TUI-style padding on the first row. - ;; meaningful-length strips per-line padding → 4 chars: "AB\nC". + ;; meaningful-input-length strips per-line padding → 4 chars: "AB\nC". (insert "AB \nC") (goto-char (point-min)) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(0 . 0)) - ((symbol-function 'evil-ghostel--cursor-to-point) #'ignore)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore)) (evil-normal-state) (let ((bs-count 0) (pasted nil)) @@ -899,7 +1365,7 @@ ranges don't)." (cl-incf bs-count)))) ((symbol-function 'ghostel--paste-text) (lambda (text) (setq pasted text)))) - (evil-replace 1 8 'inclusive ?X)) + (evil-ghostel-replace 1 8 'inclusive ?X)) ;; Pre-fix: bs-count read meaningful-length (4) but pasted used ;; raw substring length (7), leaving a stray "XXX" on screen. (should (= 4 bs-count)) @@ -910,20 +1376,20 @@ ranges don't)." ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-paste-after () - "Test that `evil-paste-after' pastes via PTY." + "`evil-ghostel-paste-after' pastes the kill ring's head via PTY." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello") (kill-new "world") (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) (ghostel--cursor-pos '(0 . 0)) - ((symbol-function 'evil-ghostel--cursor-to-point) #'ignore)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore)) (evil-normal-state) (let ((pasted nil)) (cl-letf (((symbol-function 'ghostel--paste-text) (lambda (text) (setq pasted text))) ((symbol-function 'ghostel--send-encoded) #'ignore)) - (evil-paste-after 1)) + (evil-ghostel-paste-after 1)) (should (equal "world" pasted)))))) ;; ----------------------------------------------------------------------- @@ -947,22 +1413,9 @@ ranges don't)." (evil-ghostel--passthrough-ctrl key)) (should (cl-find (cons key "ctrl") keys-sent :test #'equal))))))) -(ert-deftest evil-ghostel-test-ctrl-passthrough-invalidates-shadow () - "Ctrl passthrough must invalidate the shadow cursor. -C-a / C-e / C-u / C-w / C-r / C-n / C-p reposition the readline -cursor or swap in a different input line — a stale shadow would -mislead the next `cursor-to-point' into computing deltas from a -position the cursor no longer holds." - (evil-ghostel-test--with-evil-buffer - (setq-local ghostel--term t) - (insert "hello") - (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(5 . 0)) - ((symbol-function 'ghostel--send-encoded) #'ignore)) - (evil-insert-state) - (setq evil-ghostel--shadow-cursor (cons 5 0)) - (evil-ghostel--passthrough-ctrl "a") - (should-not evil-ghostel--shadow-cursor)))) +;; (Removed: evil-ghostel-test-ctrl-passthrough-invalidates-shadow. +;; The shadow-cursor model is gone — the new architecture reads +;; `ghostel--cursor-pos' directly each time.) ;; ----------------------------------------------------------------------- ;; Test: insert-state entry skips vertical sync @@ -1057,8 +1510,8 @@ Point and the terminal cursor are intentionally decoupled there." (cl-letf ((ghostel--cursor-pos '(0 . 0))) (evil-normal-state) (let ((sync-called nil)) - (cl-letf (((symbol-function 'evil-ghostel--cursor-to-point) - (lambda () (setq sync-called t))) + (cl-letf (((symbol-function 'evil-ghostel-goto-input-position) + (lambda (&rest _) (setq sync-called t))) ((symbol-function 'evil-ghostel--reset-cursor-point) (lambda () (setq sync-called t)))) (evil-insert-state)) @@ -1073,8 +1526,8 @@ Point and the terminal cursor are intentionally decoupled there." (ghostel--cursor-pos '(0 . 0))) (evil-normal-state) (let ((sync-called nil)) - (cl-letf (((symbol-function 'evil-ghostel--cursor-to-point) - (lambda () (setq sync-called t))) + (cl-letf (((symbol-function 'evil-ghostel-goto-input-position) + (lambda (&rest _) (setq sync-called t))) ((symbol-function 'evil-ghostel--reset-cursor-point) (lambda () (setq sync-called t)))) (evil-insert-state)) @@ -1088,7 +1541,7 @@ Point and the terminal cursor are intentionally decoupled there." (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-insert-line 1)) + (evil-ghostel-insert-line)) (should (= (point) 3)) (should (evil-insert-state-p)) (should-not (member "a" keys-sent))))) @@ -1101,7 +1554,7 @@ Point and the terminal cursor are intentionally decoupled there." (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-append-line 1)) + (evil-ghostel-append-line)) (should (= (point) 13)) (should (evil-insert-state-p)) (should-not (member "e" keys-sent))))) @@ -1142,7 +1595,7 @@ C-d (`ghostel-line-mode-delete-char-or-eof')." ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-undo-sends-ctrl-underscore () - "Test that `evil-undo' sends Ctrl+_ to the terminal." + "`evil-ghostel-undo' sends Ctrl+_ to the terminal." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) @@ -1152,7 +1605,7 @@ C-d (`ghostel-line-mode-delete-char-or-eof')." (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key mods &rest _) (push (cons key mods) keys-sent)))) - (evil-undo 3)) + (evil-ghostel-undo 3)) (should (= 3 (cl-count '("_" . "ctrl") keys-sent :test #'equal))))))) ;; ----------------------------------------------------------------------- @@ -1325,131 +1778,185 @@ inserts at the prompt position rather than at the input start." (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil))) (evil-normal-state) (goto-char (point-max)) - (evil-beginning-of-line) + (evil-ghostel-beginning-of-line) ;; Lands at col 2 (after "$ "), not col 0. (should (= 2 (current-column))) (goto-char (point-max)) - (evil-first-non-blank) + (evil-ghostel-first-non-blank) (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." +not be hijacked. + +`ghostel-beginning-of-input-or-line' itself handles the fall-through +\(it calls `move-beginning-of-line' when no prompt prop / line-mode +marker is in play), so the new motion still does the right thing +even when active-p is true." (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))) (evil-normal-state) (goto-char (point-max)) - (evil-beginning-of-line) + (evil-ghostel-beginning-of-line) (should (= 0 (current-column)))))) +;; (Removed: evil-ghostel-test-shadow-cursor-tracks-cursor-to-point and +;; evil-ghostel-test-shadow-cursor-tracks-delete-region. The shadow-cursor +;; model is gone — the new operators read `ghostel--cursor-pos' directly +;; each time and don't rely on a queued-key projection. See the +;; "Shadow-cursor: drop" analysis in plans/evil-rewrite-plan.md.) + ;; ----------------------------------------------------------------------- -;; Test: shadow cursor (queued-key model) +;; Test: cw doesn't emit redundant left arrows after delete ;; ----------------------------------------------------------------------- -(ert-deftest evil-ghostel-test-shadow-cursor-tracks-cursor-to-point () - "After `cursor-to-point' the shadow holds point's viewport position. -A second `cursor-to-point' call within the same operation must read -from the shadow rather than the still-stale live libghostty cursor — -otherwise it computes deltas from the wrong baseline and emits extra -arrows. Mocks the live cursor at (17 . 0) and verifies the second -sync emits zero keys once point is at col 6." +(ert-deftest evil-ghostel-test-delete-word-with-trailing-space () + "Regression: `dw' over `\"word \"' sends 5 backspaces, not 4. +Trailing whitespace in single-line ranges is real user content. +\(With the old per-line stripping heuristic applied to single-line +ranges, `dw' over `\"word word word\" + ESC bb' would send only 4 +backspaces — leaving a stray `w' behind.)" (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert "word1 word2 word3") + (insert "word word word") (goto-char (point-min)) - (move-to-column 6) + (move-to-column 5) ; start of word2 (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(17 . 0))) - (let ((first-keys '()) (second-keys '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) (push key first-keys)))) - (evil-ghostel--cursor-to-point)) - (should (= 11 (length first-keys))) - (should (equal '(6 . 0) evil-ghostel--shadow-cursor)) - ;; Second sync — point is unchanged, shadow already at (6 . 0), - ;; so no further keys should be emitted. + (ghostel--cursor-pos '(14 . 0))) + (let ((bs-count 0)) (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) (push key second-keys)))) - (evil-ghostel--cursor-to-point)) - (should (= 0 (length second-keys))))))) + (lambda (key _mods &rest _) + (when (equal key "backspace") (cl-incf bs-count))))) + ;; `dw' from col 5 deletes "word " (chars 6..10, exclusive end 11). + (evil-ghostel-delete 6 11 'exclusive nil nil)) + (should (= 5 bs-count)))))) -(ert-deftest evil-ghostel-test-shadow-cursor-tracks-delete-region () - "After `delete-region' the shadow advances by COUNT columns. -A follow-up `cursor-to-point' from BEG should be a no-op." +(ert-deftest evil-ghostel-test-forward-word-stops-at-input-end () + "`evil-ghostel-forward-word-begin' clamps point to the input row's end. +Vanilla `evil-forward-word-begin' would scan into the blank renderer +rows below the prompt; the wrapper clamps point to +`evil-ghostel--cursor-row-end-point' so `w' from the last input word stays +on the cursor row." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert "word1 word2 word3") - (goto-char (point-min)) - (move-to-column 6) + (let ((inhibit-read-only t)) + (insert (propertize "% " 'ghostel-prompt t)) + (insert "word word") + (insert "\n\n\n\n")) + (goto-char 8) ; start of last "word" in input (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(17 . 0))) - (cl-letf (((symbol-function 'ghostel--send-encoded) #'ignore)) - (evil-ghostel--delete-region 7 12)) - ;; Shadow is at end-col (11) - count (5) = 6, viewport row 0. - (should (equal '(6 . 0) evil-ghostel--shadow-cursor)) - ;; Point is at col 6 (beg). cursor-to-point should now be a no-op. - (let ((extra-keys '())) - (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) (push key extra-keys)))) - (evil-ghostel--cursor-to-point)) - (should (= 0 (length extra-keys))))))) - -;; ----------------------------------------------------------------------- -;; Test: cw doesn't emit redundant left arrows after delete -;; ----------------------------------------------------------------------- - -(ert-deftest evil-ghostel-test-delete-word-with-trailing-space () - "Regression: `dw' over `\"word \"' must send 5 backspaces, not 4. -With the old `meaningful-length' the trailing space was always -stripped, so `dw' on `\"word word word\" + ESC bb' sent only 4 -backspaces — leaving a stray `w' behind (`word wword' instead of -`word word'). Trailing whitespace in single-line ranges is real + (ghostel--cursor-pos '(7 . 0)) + (ghostel--cursor-char-pos 8)) + (evil-normal-state) + (evil-ghostel-forward-word-begin 1) + ;; Clamped to row-end (past "word" on row 0), not point-of-next-line. + (should (= 1 (line-number-at-pos)))))) + +(ert-deftest evil-ghostel-test-forward-word-falls-through-off-cursor-row () + "Off the cursor row, the wrapper delegates to `evil-forward-word-begin'. +Scrollback navigation must keep working — clamping only kicks in on +the cursor's row where empty cells past end-of-input are not real content." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) - (insert "word word word") - (goto-char (point-min)) - (move-to-column 5) ; start of word2 + (let ((inhibit-read-only t)) + (insert "hello world\n") + (insert (propertize "% " 'ghostel-prompt t)) + (insert "cmd")) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(14 . 0))) + ;; Cursor on row 1; we'll move point onto row 0 (scrollback). + (ghostel--cursor-pos '(5 . 1)) + (ghostel--cursor-char-pos 18)) + (evil-normal-state) + (goto-char (point-min)) ; point on row 0 (scrollback row) + (evil-ghostel-forward-word-begin 1) + ;; Vanilla forward-word-begin from "hello" lands on "world" (col 6). + (should (= 6 (current-column)))))) + +(ert-deftest evil-ghostel-test-delete-word-on-last-word-clamps-overshoot () + "Regression: `dw' on the last input word clamps motion overshoot. +With input `\"word word\"' and cursor mid-input, the motion `w' +walks off the cursor row (no next word on this line) so END +lands on a buffer row below the cursor. The operator-level +clamp trims END to `evil-ghostel--cursor-row-end-point' so backspaces +target only the typed characters, not the renderer-painted +padding/blanks past end-of-input." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + ;; Simulate the post-bbcw state: prompt-prefixed input "word word" + ;; with cursor mid-input and several blank renderer rows below. + (let ((inhibit-read-only t)) + (insert (propertize "% " 'ghostel-prompt t)) + (insert "word word") + (insert "\n\n\n\n")) ; blank renderer rows below row 0 + (goto-char 8) ; col 5 in input = start of last "word" + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(7 . 0)) + (ghostel--cursor-char-pos 8)) + (evil-normal-state) (let ((bs-count 0)) (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (when (equal key "backspace") (cl-incf bs-count))))) - ;; `dw' from col 5 deletes "word " (chars 6..10, exclusive end 11). - (evil-delete 6 11 'exclusive nil nil)) - (should (= 5 bs-count)))))) + ;; Motion `w' from pos 8 walks past end-of-input to a blank + ;; row below — simulate by passing END beyond the cursor row. + (evil-ghostel-delete 8 13 'exclusive nil nil)) + ;; Clamp trims END to row-end (pos 12, after "word"), so the + ;; delete sends 4 backspaces for "word", not 5+ for "word\n..." + (should (= 4 bs-count)))))) (ert-deftest evil-ghostel-test-change-partial-no-post-delete-sync () - "After `cw' (count > 0) `around-change' must not run a second -post-delete cursor-to-point. The redundant sync used to read the -stale live cursor and emit extra left arrows that pushed the -terminal cursor past the start of input — observed as `cw seems -to move the point to the beginning of the line'." + "After `cw' (count > 0) the post-delete `evil-ghostel-insert' is idempotent. +Once the shell has echoed our 6 LEFT + 5 BACKSPACE the live cursor +sits at the same buffer position as point — `evil-ghostel-insert' → +`goto-input-position' computes dx=dy=0 and sends nothing further. +The mock updates `ghostel--cursor-pos' from the keys we emit to +mirror that drain behaviour." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "word1 word2 word3") (goto-char (point-min)) (move-to-column 6) + (setq ghostel--cursor-pos '(17 . 0)) + (setq ghostel--cursor-char-pos (+ (point-min) 17)) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) - (ghostel--cursor-pos '(17 . 0))) + ((symbol-function 'evil-ghostel--sync-render) + (lambda (&rest _) nil))) ; we already update pos inline (let ((keys-sent '())) (cl-letf (((symbol-function 'ghostel--send-encoded) - (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-change 7 12 'exclusive nil nil)) + (lambda (key _mods &rest _) + (push key keys-sent) + ;; Simulate the shell echo updating cursor-pos. + (pcase key + ((or "left" "backspace") + (let ((col (car ghostel--cursor-pos)) + (row (cdr ghostel--cursor-pos))) + (setq ghostel--cursor-pos (cons (max 0 (1- col)) row)) + (when ghostel--cursor-char-pos + (setq ghostel--cursor-char-pos + (max (point-min) + (1- ghostel--cursor-char-pos)))))) + ("right" + (let ((col (car ghostel--cursor-pos)) + (row (cdr ghostel--cursor-pos))) + (setq ghostel--cursor-pos (cons (1+ col) row)) + (when ghostel--cursor-char-pos + (setq ghostel--cursor-char-pos + (1+ ghostel--cursor-char-pos))))))))) + (evil-ghostel-change 7 12 'exclusive nil nil)) (let* ((seq (nreverse keys-sent)) (left-count (cl-count "left" seq :test #'equal)) + (right-count (cl-count "right" seq :test #'equal)) (bs-count (cl-count "backspace" seq :test #'equal))) - ;; Exactly one initial sync (6 lefts: col 17 → col 11 = end) - ;; and the 5 backspaces. No second sync after backspaces — - ;; with the bug, that second sync read the stale live cursor - ;; (col 17) against point's now-col-6 and emitted 11 more - ;; left arrows, pushing the terminal cursor past col 0. + ;; 6 LEFTs to drive cursor to END (col 17 → 11) then 5 backspaces. + ;; The post-delete `evil-ghostel-insert' is a no-op once cursor-pos + ;; has caught up — no extra LEFTs, no spurious RIGHT. (should (= 6 left-count)) - (should (= 5 bs-count))))))) + (should (= 5 bs-count)) + (should (zerop right-count))))))) ;; ----------------------------------------------------------------------- ;; Test: insert-state-entry uses viewport row, not buffer line @@ -1477,10 +1984,10 @@ and silently undoing the user's `^' / `$' / `0' navigation." (let ((reset-called nil) (sync-called nil)) (cl-letf (((symbol-function 'evil-ghostel--reset-cursor-point) (lambda () (setq reset-called t))) - ((symbol-function 'evil-ghostel--cursor-to-point) - (lambda () (setq sync-called t)))) + ((symbol-function 'evil-ghostel-goto-input-position) + (lambda (&rest _) (setq sync-called t)))) (evil-ghostel--insert-state-entry)) - ;; Same viewport row → cursor-to-point, NOT reset-cursor-point. + ;; Same viewport row → goto-input-position, NOT reset-cursor-point. (should sync-called) (should-not reset-called))))) @@ -1508,6 +2015,243 @@ sticks." (should (= 0 (current-column))) (should (= 1 (line-number-at-pos))))) +;; ----------------------------------------------------------------------- +;; Test: forward-char / backward-char / end-of-line clamps +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-forward-char-clamps-at-row-end () + "`evil-ghostel-forward-char' stops at `evil-ghostel--cursor-row-end-point' +on the cursor row. Trailing renderer cells (stale glyphs from prior +input, RPROMPT padding) sit between cursor and physical EOL; vanilla +`evil-forward-char' walks through them, the wrapper clamps it back." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + ;; "cmd" + 10 spaces of trailing renderer padding, then \n. + (insert "cmd \n")) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ;; Live cursor at end of "cmd" (col 5, pos 6). + (ghostel--cursor-pos '(5 . 0)) + (ghostel--cursor-char-pos 6)) + (evil-normal-state) + (goto-char 3) ; start of "cmd" + ;; Try to walk 8 chars right — vanilla would land in trailing padding. + (evil-ghostel-forward-char 8) + ;; Clamped to end of "cmd" on the cursor row (pos 6). + (should (= 6 (point)))))) + +(ert-deftest evil-ghostel-test-forward-char-falls-through-off-cursor-row () + "Off the cursor row, `evil-ghostel-forward-char' delegates to vanilla. +Scrollback navigation keeps working — clamping only kicks in on +the cursor's row." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (let ((inhibit-read-only t)) + (insert "hello world\n") + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "cmd")) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ;; Cursor on row 1; we'll move point onto row 0 (scrollback). + (ghostel--cursor-pos '(5 . 1)) + (ghostel--cursor-char-pos 18)) + (evil-normal-state) + (goto-char (point-min)) ; row 0 (scrollback) + (evil-ghostel-forward-char 5) + ;; Vanilla forward-char advances 5 columns. + (should (= 5 (current-column)))))) + +(ert-deftest evil-ghostel-test-backward-char-clamps-at-input-start () + "`evil-ghostel-backward-char' stops at `ghostel-input-start-point' on +the cursor row, so `h' can't walk into the prompt prefix." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "cmd")) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(5 . 0)) + (ghostel--cursor-char-pos 6)) + (evil-normal-state) + (goto-char 6) ; end of "cmd" + (evil-ghostel-backward-char 100) + ;; Clamped to input-start (just past "$ "). + (should (= 3 (point)))))) + +(ert-deftest evil-ghostel-test-end-of-line-clamps-at-row-end () + "`evil-ghostel-end-of-line' (`$') stops at the last input char, not +on trailing renderer cells. With `(insert \"cmd \")' the buffer's +physical end-of-line is at column 5 but only `cmd' is input." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "cmd ")) ; trailing renderer padding + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(5 . 0)) + (ghostel--cursor-char-pos 6)) + (evil-normal-state) + (goto-char 3) ; start of "cmd" + (evil-ghostel-end-of-line 1) + ;; Clamped to end-of-input (after "cmd"), not after the trailing spaces. + (should (= 6 (point)))))) + +(ert-deftest evil-ghostel-test-end-of-line-falls-through-off-cursor-row () + "Off the cursor row, `$' falls through to vanilla `evil-end-of-line'." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (let ((inhibit-read-only t)) + (insert "long scrollback line\n") + (insert (propertize "$ " 'ghostel-prompt t))) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(2 . 1)) + (ghostel--cursor-char-pos 24)) + (evil-normal-state) + (goto-char (point-min)) ; row 0 + (evil-ghostel-end-of-line 1) + ;; Vanilla end-of-line reaches the actual buffer-line end on row 0. + ;; In normal state evil places point one column before the \n, so + ;; column == length - 1 = 19 for "long scrollback line". + (should (= 1 (line-number-at-pos))) + (should (= (1- (length "long scrollback line")) (current-column)))))) + +;; ----------------------------------------------------------------------- +;; Test: next-line clamp (j cannot go below cursor row) +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-next-line-clamps-at-cursor-row () + "`evil-ghostel-next-line' (`j') doesn't move below the cursor's row. +Prevents stranding the user on empty renderer rows below the live +prompt." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (setq-local ghostel--term-rows 5) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + (insert "cmd") + (insert "\n\n\n\n")) ; blank renderer rows below row 0 + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + (ghostel--cursor-pos '(5 . 0))) + (evil-normal-state) + (goto-char (point-min)) + (evil-ghostel-next-line 10) + ;; Clamped to the cursor's buffer line (line 1). + (should (= 1 (line-number-at-pos)))))) + +(ert-deftest evil-ghostel-test-next-line-falls-through-outside-semi-char () + "Outside semi-char `evil-ghostel-next-line' delegates to vanilla." + (evil-ghostel-test--with-evil-buffer + ;; ghostel--term nil → evil-ghostel--active-p returns nil. + (let ((inhibit-read-only t)) + (insert "a\nb\nc\nd\ne\n")) + (evil-normal-state) + (goto-char (point-min)) + (evil-ghostel-next-line 2) + (should (= 3 (line-number-at-pos))))) + +;; ----------------------------------------------------------------------- +;; Test: G (goto-cursor) maps to live terminal cursor +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-goto-cursor-resets-to-cursor () + "`evil-ghostel-goto-cursor' (`G') invokes `reset-cursor-point' in +semi-char. Replaces `evil-goto-line' so `G' lands on the live +prompt instead of the (post-cursor) end of buffer." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil))) + (let ((reset-called nil)) + (cl-letf (((symbol-function 'evil-ghostel--reset-cursor-point) + (lambda () (setq reset-called t)))) + (evil-ghostel-goto-cursor)) + (should reset-called))))) + +(ert-deftest evil-ghostel-test-goto-cursor-falls-through-outside-semi-char () + "`G' falls through to `evil-goto-line' when not in semi-char." + (evil-ghostel-test--with-evil-buffer + ;; ghostel--term nil → not active. + (let ((goto-called nil)) + (cl-letf (((symbol-function 'evil-goto-line) + ;; `call-interactively' requires `interactive', so the + ;; mock must declare it even though we ignore arguments. + (lambda (&rest _) (interactive) (setq goto-called t)))) + (evil-ghostel-goto-cursor)) + (should goto-called)))) + +;; ----------------------------------------------------------------------- +;; Test: append vanilla-fallthrough clamps to row-end +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-append-before-cursor-clamps-to-row-end () + "Regression: `a' before the cursor must clamp the `forward-char' +landing position to `evil-ghostel--cursor-row-end-point'. Without +the clamp `forward-char' can walk past end-of-input onto trailing +renderer cells (RPROMPT, autosuggest, stale glyphs) and the visual +cursor jumps to the right edge of the window." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (let ((inhibit-read-only t)) + (insert (propertize "$ " 'ghostel-prompt t)) + ;; "cmd" + render padding to column 20 (e.g. RPROMPT padding). + (insert "cmd") + (insert " ")) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ;; Live cursor at column 5 (just after "cmd"). Point is + ;; one to the left of the cursor — vanilla fall-through. + (ghostel--cursor-pos '(5 . 0)) + (ghostel--cursor-char-pos 6)) + (evil-normal-state) + (goto-char 5) ; on "d", before cursor + (evil-ghostel-append) + ;; After append: forward-char would reach pos 7, but row-end is 6 + ;; (after "cmd"). Clamped to 6. + (should (= 6 (point)))))) + +;; ----------------------------------------------------------------------- +;; Test: insert-state sends PTY key +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-delete-key-sends-pty () + "`' in insert state sends the `delete' PTY key in semi-char." + (evil-ghostel-test--with-evil-buffer + (setq-local ghostel--term t) + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil))) + (let ((keys-sent '())) + (cl-letf (((symbol-function 'ghostel--send-encoded) + (lambda (key _mods &rest _) (push key keys-sent)))) + (evil-ghostel--passthrough-delete)) + (should (equal '("delete") keys-sent)))))) + +(ert-deftest evil-ghostel-test-delete-key-bound-in-insert-state () + "`' is bound to `evil-ghostel--passthrough-delete' in insert state." + (should (eq #'evil-ghostel--passthrough-delete + (lookup-key (evil-get-auxiliary-keymap + evil-ghostel-mode-map 'insert) + (kbd ""))))) + +;; ----------------------------------------------------------------------- +;; Test: prompt-nav bindings and extended Ctrl passthrough +;; ----------------------------------------------------------------------- + +(ert-deftest evil-ghostel-test-prompt-nav-bound-in-normal () + "`[[' and `]]' are bound to ghostel's prompt-nav commands." + (should (eq #'ghostel-previous-prompt + (lookup-key (evil-get-auxiliary-keymap + evil-ghostel-mode-map 'normal) + (kbd "[[")))) + (should (eq #'ghostel-next-prompt + (lookup-key (evil-get-auxiliary-keymap + evil-ghostel-mode-map 'normal) + (kbd "]]"))))) + +(ert-deftest evil-ghostel-test-ctrl-passthrough-includes-vterm-set () + "Passthrough list contains every Ctrl key vterm passes through +except `z' (kept for `evil-emacs-state' escape hatch)." + (dolist (k '("a" "b" "d" "e" "f" "k" "l" "n" "o" "p" + "q" "r" "s" "t" "u" "v" "w" "y")) + (should (member k evil-ghostel--ctrl-passthrough-keys))) + (should-not (member "z" evil-ghostel--ctrl-passthrough-keys))) + ;; ----------------------------------------------------------------------- ;; Runner ;; ----------------------------------------------------------------------- @@ -1515,19 +2259,42 @@ sticks." (defconst evil-ghostel-test--elisp-tests '(evil-ghostel-test-mode-activation evil-ghostel-test-mode-deactivation + evil-ghostel-test-advice-survives-disable-in-other-buffer evil-ghostel-test-escape-stay - evil-ghostel-test-advice-on-insert - evil-ghostel-test-advice-on-append - evil-ghostel-test-advice-insert-line-sends-home - evil-ghostel-test-advice-append-line-sends-end - evil-ghostel-test-insert-line-multiline-syncs-row - evil-ghostel-test-append-line-multiline-syncs-row - evil-ghostel-test-change-eol-syncs-cursor-to-point - evil-ghostel-test-advice-no-op-outside-ghostel + evil-ghostel-test-insert-drives-shell-cursor + evil-ghostel-test-append-drives-shell-cursor + evil-ghostel-test-append-at-cursor-does-not-advance + evil-ghostel-test-append-after-cursor-moved-mid-input-advances + evil-ghostel-test-insert-on-rprompt-clamps-to-row-end + evil-ghostel-test-append-before-cursor-uses-vanilla + evil-ghostel-test-insert-line-sends-arrows-to-input-start + evil-ghostel-test-append-line-sends-arrows-to-row-end + evil-ghostel-test-insert-line-pins-point-at-input-start + evil-ghostel-test-append-line-pins-point-at-row-end + evil-ghostel-test-change-eol-snaps-point-to-cursor + evil-ghostel-test-insert-state-entry-no-op-outside-ghostel evil-ghostel-test-meaningful-length-strips-trailing + evil-ghostel-test-cursor-row-end-point-returns-eol + evil-ghostel-test-cursor-row-end-point-respects-input-property + evil-ghostel-test-cursor-row-end-point-clamps-at-right-prompt-gap + evil-ghostel-test-cursor-row-end-point-uses-first-input-region + evil-ghostel-test-cursor-row-end-point-tight-gap-keeps-input + evil-ghostel-test-end-of-line-clamps-past-right-prompt + evil-ghostel-test-point-in-input-p-true-between-prompt-and-eol + evil-ghostel-test-point-in-input-p-false-on-prompt-char + evil-ghostel-test-clamp-to-input-narrows-on-cursor-row + evil-ghostel-test-clamp-to-input-trims-end-past-cursor + evil-ghostel-test-clamp-to-input-passes-through-off-row + evil-ghostel-test-goto-input-position-sends-arrows-unit + evil-ghostel-test-goto-input-position-no-op-at-target + evil-ghostel-test-sync-render-forces-deferred-redraw + evil-ghostel-test-sync-render-no-op-when-nothing-deferred + evil-ghostel-test-sync-render-drain-loop-respects-cap + evil-ghostel-test-delete-input-region-sends-backspaces + evil-ghostel-test-replace-input-region-deletes-then-pastes evil-ghostel-test-delete-sends-backspace-keys - evil-ghostel-test-delete-line-same-row-uses-ctrl-u - evil-ghostel-test-change-line-same-row-uses-ctrl-u + evil-ghostel-test-delete-line-same-row-uses-backspaces + evil-ghostel-test-change-line-same-row-uses-backspaces evil-ghostel-test-delete-line-multiline-syncs-cursor evil-ghostel-test-delete-line-strips-render-padding evil-ghostel-test-replace-counts-match-on-trailing-space @@ -1551,12 +2318,26 @@ 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-shadow-cursor-tracks-cursor-to-point - evil-ghostel-test-shadow-cursor-tracks-delete-region evil-ghostel-test-delete-word-with-trailing-space + evil-ghostel-test-delete-word-on-last-word-clamps-overshoot + evil-ghostel-test-forward-word-stops-at-input-end + evil-ghostel-test-forward-word-falls-through-off-cursor-row evil-ghostel-test-change-partial-no-post-delete-sync evil-ghostel-test-insert-entry-same-viewport-row-with-scrollback - evil-ghostel-test-ctrl-passthrough-invalidates-shadow) + evil-ghostel-test-forward-char-clamps-at-row-end + evil-ghostel-test-forward-char-falls-through-off-cursor-row + evil-ghostel-test-backward-char-clamps-at-input-start + evil-ghostel-test-end-of-line-clamps-at-row-end + evil-ghostel-test-end-of-line-falls-through-off-cursor-row + evil-ghostel-test-next-line-clamps-at-cursor-row + evil-ghostel-test-next-line-falls-through-outside-semi-char + evil-ghostel-test-goto-cursor-resets-to-cursor + evil-ghostel-test-goto-cursor-falls-through-outside-semi-char + evil-ghostel-test-append-before-cursor-clamps-to-row-end + evil-ghostel-test-delete-key-sends-pty + evil-ghostel-test-delete-key-bound-in-insert-state + evil-ghostel-test-prompt-nav-bound-in-normal + evil-ghostel-test-ctrl-passthrough-includes-vterm-set) "Tests that require only Elisp (no native module).") (defun evil-ghostel-test-run-elisp ()