From 310826b1c99839b23aea884a5a14fa1f642892f2 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Wed, 13 May 2026 13:29:23 +0200 Subject: [PATCH] Rewrite evil-ghostel from advice-based to command-remap architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces ~13 advice-add hooks on evil-* commands with proper evil-define-operator / evil-define-motion definitions bound via evil-ghostel-mode-map for normal and visual states. New evil-ghostel commands: -delete (and -line/-char/-backward-char), -change (and -line), -substitute (and -line), -replace, -paste-after, -paste-before, -insert (and -line), -append (and -line), -beginning-of-line, -first-non-blank, -forward-word-begin/-WORD-begin/-word-end/-WORD-end, -undo, -redo. Bound in evil-ghostel-mode-map; forward-word motions are normal-only so operator-pending (dw, cw) uses vanilla motion + operator clamp. Drops the shadow-cursor model and all advice on evil-* commands. Keeps advice on `ghostel--redraw' / `ghostel--set-cursor-style' and the insert-state-entry hook (essential plumbing). PTY-driven input editing helpers (new, live here because they all depend on a cooperative line editor — readline / zle / prompt_toolkit accepting arrow keys, backspace, bracketed paste): - evil-ghostel-goto-input-position — moves the terminal cursor via arrow keys, with vterm-style recovery for literal `^[[C' echo and bash-autosuggest accept-on-right-arrow. - evil-ghostel-delete-input-region, -replace-input-region. - evil-ghostel-point-in-input-p — predicate for the editable input region. - evil-ghostel--clamp-to-input — trims an operator's range to the live input region. Clamps END to row-end on forward overshoot so `dw' on the last input word no longer over-deletes into blank renderer rows below the prompt. - evil-ghostel--cursor-row-end-point — strips renderer-emitted trailing whitespace. - evil-ghostel--input-start-from-prop, --meaningful-input-length, --sync-render — internal helpers. Tests: 88 evil-ghostel tests covering operators, motions, paste, undo/redo, escape handling, plus unit tests for the new input-region helpers. --- extensions/evil-ghostel/evil-ghostel.el | 1139 ++++++++++++++++------- test/evil-ghostel-test.el | 1002 ++++++++++++++------ 2 files changed, 1516 insertions(+), 625 deletions(-) diff --git a/extensions/evil-ghostel/evil-ghostel.el b/extensions/evil-ghostel/evil-ghostel.el index 4136922..52725c9 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: ;; @@ -76,10 +84,13 @@ Sets the initial value of the buffer-local state. Use (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,58 +164,6 @@ 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 @@ -281,12 +240,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))) @@ -307,16 +261,18 @@ In alt-screen mode, defer to the terminal's cursor style." (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.") +Set by commands that already repositioned the terminal cursor by their +own means (e.g. `evil-ghostel-insert-line', the change operators).") (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. +Skipped when `evil-ghostel--sync-inhibit' is set (a command on the +caller's stack already positioned the cursor). 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. + 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." @@ -335,298 +291,734 @@ which the shell would interpret as history navigation." ;; user's `^', `$', `0' navigation). (erow (or (evil-ghostel--point-viewport-row) 0))) (if (= erow trow) - (evil-ghostel--cursor-to-point) + (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)) + +;; Internal helper: clear the active input line via readline shortcuts -;; 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. +(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") + (setq evil-ghostel--sync-point-on-next-redraw t)) -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 + +;; 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--cursor-row-end-point () + "Return the position just after the last non-whitespace char of the cursor row. +Strips trailing whitespace (renderer-emitted padding cells past the +user's last typed character) so PTY-routed clamping doesn't +over-delete into terminal cells that aren't actual input characters. -Falls through to ORIG-FN when ghostel isn't active or the row has -no prompt to skip — preserving standard motion semantics in -scrollback, output, and non-prompt rows." +Tradeoff: trailing whitespace that is genuinely user-typed (rare, +e.g. `cmd') is also stripped — most callers prefer +that to over-deleting into padding." + (when ghostel--cursor-char-pos + (save-excursion + (goto-char ghostel--cursor-char-pos) + (end-of-line) + (skip-chars-backward " \t" (line-beginning-position)) + (point)))) + +(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. + +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))) + +(defun evil-ghostel--sync-render () + "Drain pending PTY output so cursor state reflects the latest echo. +Calls `accept-process-output' with `just-this-one' set so other +subprocesses are not advanced; the filter's immediate-redraw branch +then handles small echoes synchronously, updating +`ghostel--cursor-pos' and `ghostel--cursor-char-pos' before this +returns. Used by `evil-ghostel-goto-input-position' to verify +post-send state and detect inner-program pathologies." + (when (and ghostel--process (process-live-p ghostel--process)) + (accept-process-output ghostel--process 0.1 nil t))) + +(defun evil-ghostel-goto-input-position (pos) + "Move the terminal cursor to buffer position POS via arrow keys. +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. + +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))) + (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). + ;; Left/up/down don't have analogous failure modes — skip the + ;; sync to keep the common case latency-free. Echo detection + ;; also requires `ghostel--cursor-char-pos' (the rendered + ;; baseline) — when that's nil, treat the bulk send as success. + (if (or (<= dx 0) (null start-char-pos)) + 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))))))) + +(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). 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" ""))) + 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 in ghostel buffer. +When in semi-char mode and point is on a non-cursor row (e.g. +parked in scrollback), snap to input-start first so typed +characters land somewhere useful." + (interactive) + (when (and (evil-ghostel--active-p) + (not (ghostel-point-on-cursor-row-p))) + (when-let* ((start (ghostel-input-start-point))) + (goto-char start))) + (call-interactively #'evil-insert)) + +(defun evil-ghostel-insert-line () + "Move to the start of the current input line, then enter insert state. +In semi-char, syncs the terminal cursor to point's row first (so +the inner program operates on the right row in multi-line TUIs) +then sends Ctrl-a — readline / zle / prompt_toolkit all bind that +to beginning-of-line. In line mode, jumps point to +`ghostel--line-input-start' and inhibits the sync hook (markers +and PTY cursor are decoupled). Outside ghostel, runs vanilla +`evil-insert-line'." + (interactive) (cond ((evil-ghostel--active-p) - (evil-ghostel--cursor-to-point) + (evil-ghostel-goto-input-position (point)) (ghostel--send-encoded "a" "ctrl") - (evil-ghostel--invalidate-shadow) (setq evil-ghostel--sync-inhibit t) - (apply orig-fn args)) + (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 in ghostel buffer. +When point sits at or past the terminal cursor (end of typed +input on the cursor row), enter insert state without advancing. +Vanilla `evil-append' would `forward-char' over a renderer cell +that isn't real input (zsh-autosuggestions hint, RPROMPT padding, +or any post-cursor cell the renderer kept because it isn't +trailing-blank). Without the guard the visual cursor lands one +cell past `d' and a subsequent backspace would still erase `d', +not the cell point appears to be on. + +When point is on a non-cursor row, snap to input-start first +\(prevents inserting into scrollback)." + (interactive) + (cond + ((not (evil-ghostel--active-p)) + (call-interactively #'evil-append)) + ;; Off the cursor row: snap to input-start, enter insert state. + ((not (ghostel-point-on-cursor-row-p)) + (when-let* ((start (ghostel-input-start-point))) + (goto-char start)) + (evil-insert-state 1)) + ;; On the cursor row at or past the live cursor, AND the cell at + ;; the cursor is renderer padding (blank): vanilla `forward-char' + ;; would visually advance onto a non-input cell. Just enter insert + ;; state. When the cell at the cursor is typed text (non-blank), + ;; the cursor was moved mid-input by a previous sync (e.g. `i' then + ;; `' then `a') and we want vim's append-after-current-char + ;; semantic — fall through to the vanilla branch. + ((when-let* ((cur (ghostel-cursor-point))) + (and (>= (point) cur) + (save-excursion + (goto-char cur) + (or (eolp) + (looking-at-p "[ \t]"))))) + (evil-insert-state 1)) + ;; On the cursor row before the cursor: emulate `evil-append' + ;; (forward-char + insert state) but clamp the post-`forward-char' + ;; position to `evil-ghostel--cursor-row-end-point'. Without the + ;; clamp `forward-char' can walk past end-of-input onto trailing + ;; renderer cells (RPROMPT padding, stale glyphs) and the visual + ;; cursor jumps to the right edge of the window. + (t + (unless (eolp) (forward-char)) + (let ((row-end (evil-ghostel--cursor-row-end-point))) + (when (and row-end (> (point) row-end)) + (goto-char row-end))) + (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': sync the terminal cursor +to point's row in semi-char, then send Ctrl-e (readline / zle +end-of-line). Line mode jumps to `ghostel--line-input-end'." + (interactive) (cond ((evil-ghostel--active-p) - (evil-ghostel--cursor-to-point) + (evil-ghostel-goto-input-position (point)) (ghostel--send-encoded "e" "ctrl") - (evil-ghostel--invalidate-shadow) (setq evil-ghostel--sync-inhibit t) - (apply orig-fn args)) + (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)) + ((and (eq type 'line) (ghostel-point-on-cursor-row-p beg)) + ;; Single-line shell case: readline shortcut clears the + ;; input area without us needing prompt geometry. + (evil-ghostel--clear-input-line)) + (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. - -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))) + +;; 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. + +When the range is empty (e.g. C at end-of-line on a non-cursor +row), the delete sends no keys and the terminal cursor stays where +it is — call `evil-ghostel-goto-input-position' explicitly so insert +state lands on point's row. + +Covers c, cc, s." + (interactive "") + (if (not (evil-ghostel--active-p)) + (evil-change beg end type register yank-handler delete-func) + (let ((empty-p (= beg end))) + (evil-ghostel-delete beg end type register yank-handler) + (when empty-p + (evil-ghostel-goto-input-position (point))) + (setq evil-ghostel--sync-inhibit t) + (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. +On an empty range (point already at EOL), explicitly syncs the +terminal cursor to point's row before entering insert state — +otherwise the user's typed characters would land on whatever row +the cursor was last parked on. + +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) + (let* ((line-end (save-excursion (goto-char (or beg (point))) + (line-end-position))) + (empty-p (= (or beg (point)) (min (or end (point)) line-end)))) + (evil-ghostel-delete-line beg end type register yank-handler) + (when empty-p + (evil-ghostel-goto-input-position (point))) + (setq evil-ghostel--sync-inhibit t) + (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)) -(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)))) + +;; 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--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 +1029,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 +1047,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 @@ -737,8 +1178,8 @@ The mode is buffer-local; see `evil-ghostel-escape' for the default." ;;;###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." :lighter nil :keymap evil-ghostel-mode-map (if evil-ghostel-mode @@ -751,19 +1192,6 @@ 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 'ghostel--redraw :around #'evil-ghostel--around-redraw) (advice-add 'ghostel--set-cursor-style :around #'evil-ghostel--override-cursor-style) @@ -772,19 +1200,6 @@ 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))) diff --git a/test/evil-ghostel-test.el b/test/evil-ghostel-test.el index af0c639..696e05d 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'. @@ -323,66 +349,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 +388,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)))))))) @@ -466,20 +461,22 @@ redrawing elsewhere." ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-advice-on-insert () - "Test that `evil-ghostel--before-insert' fires on `evil-insert'." + "`evil-ghostel-insert' triggers the insert-state-entry sync. +The hook 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'." + "`evil-ghostel-append' triggers the insert-state-entry sync." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "hello") @@ -489,13 +486,87 @@ 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-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 `forward-char' when point is at or +past `ghostel-cursor-point'." + (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)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore) + (ghostel--cursor-pos '(4 . 0)) + (ghostel--cursor-char-pos 5)) + (evil-normal-state) + (goto-char 5) ; point AT cursor-char-pos (end of typed input) + (evil-ghostel-append) + ;; Without the guard, evil-append would forward-char to pos 6 + ;; (onto a padding space). The guard keeps point at the cursor. + (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 pre-fix guard used `point >= cursor' and +mis-fired — the cursor was no longer at end-of-typed-input. The fix +also checks whether the cell at the cursor is renderer padding (blank); +when it is non-blank typed text, fall through to the vanilla branch." + (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)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore) + ;; Cursor moved to mid-input by a previous sync. + (ghostel--cursor-pos '(1 . 0)) + (ghostel--cursor-char-pos 2)) + (evil-normal-state) + (goto-char 2) ; point on `i', same as cursor + (evil-ghostel-append) + ;; The cell at cursor (pos 2, "i") is non-blank typed text → the + ;; guard correctly falls through to vanilla, which advances to 3. + (should (= 3 (point))) + (should (eq 'insert evil-state))))) + +(ert-deftest evil-ghostel-test-append-before-cursor-uses-vanilla () + "Append mid-input falls through to vanilla `evil-append'. +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 "hello world") + (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) + ((symbol-function 'evil-ghostel-goto-input-position) #'ignore) + (ghostel--cursor-pos '(11 . 0)) + (ghostel--cursor-char-pos 12)) + (evil-normal-state) + (goto-char 3) ; point on 'e' of "hello" + (evil-ghostel-append) + ;; Vanilla evil-append advances by 1 then enters insert. + (should (= 4 (point))) + (should (eq 'insert evil-state))))) + (ert-deftest evil-ghostel-test-advice-insert-line-sends-home () - "Test that `evil-insert-line' sends C-a and inhibits hook sync." + "`evil-ghostel-insert-line' sends C-a and inhibits hook sync." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) @@ -505,14 +576,15 @@ redrawing elsewhere." (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-insert-line 1)) + (evil-ghostel-insert-line)) (should (member "a" keys-sent)) - ;; Hook should NOT have sent additional arrow keys + ;; sync-inhibit prevents the hook from sending additional arrow + ;; keys after C-a (which would override the readline cursor). (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." + "`evil-ghostel-append-line' sends C-e and inhibits hook sync." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (cl-letf (((symbol-function 'ghostel--mode-enabled) (lambda (&rest _) nil)) @@ -522,17 +594,16 @@ redrawing elsewhere." (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key _mods &rest _) (push key keys-sent)))) - (evil-append-line 1)) + (evil-ghostel-append-line)) (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'." + "Regression: `I' on a different row syncs the terminal cursor first. +Without the row sync, the Ctrl-a operates on the last input line +\(where the terminal cursor was parked), not on the line the user +navigated to." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "line one\nline two\nline three") @@ -545,16 +616,12 @@ line the user navigated to with `kk'." (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. + (evil-ghostel-insert-line)) (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." + "Regression: `A' on a different row syncs the terminal cursor first." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "line one\nline two\nline three") @@ -566,17 +633,17 @@ goes to the end of the last input line." (cl-letf (((symbol-function 'ghostel--send-encoded) (lambda (key mods &rest _) (push (cons key mods) keys-sent)))) - (evil-append-line 1)) + (evil-ghostel-append-line)) (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'." + "Regression: `C' at eol of a non-cursor row syncs before insert. +With point at end of line one and the terminal cursor at end of +line three, `C' produces an empty range to delete. The +insert-state-entry sync (called via the hook on `evil-insert') +must emit two UP arrows so the terminal cursor lands on point's +row before the user's typed characters arrive." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "line one\nline two\nline three") @@ -591,11 +658,8 @@ 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. + (evil-ghostel-change-line eol-pos eol-pos 'inclusive nil nil)) + ;; Two `up' arrows so the terminal cursor lands on point's row. (should (= 2 (cl-count '("up" . "") keys-sent :test #'equal))))))) ;; ----------------------------------------------------------------------- @@ -603,13 +667,13 @@ the last input line — what was reported as `C deletes the last line'." ;; ----------------------------------------------------------------------- (ert-deftest evil-ghostel-test-advice-no-op-outside-ghostel () - "Test that advice does nothing when `evil-ghostel-mode' is nil." + "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 +697,186 @@ 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. + +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)) + ,@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-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-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,11 +884,11 @@ 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. + "`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. @@ -705,17 +904,18 @@ See issue #218 for the multi-line counterpart." (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)) + (evil-ghostel-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. + ;; 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. + "`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'." (evil-ghostel-test--with-evil-buffer @@ -728,19 +928,20 @@ Same single-line shell rationale as `dd' — see (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)) + (evil-ghostel-change (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)) (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 +955,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 +965,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 +981,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 +990,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 +1014,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 +1042,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 +1050,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 +1067,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 +1096,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 +1107,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 +1144,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 +1241,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 +1257,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 +1272,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 +1285,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 +1326,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 +1336,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,110 +1509,141 @@ 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 operator runs no extra post-delete sync. +A redundant sync would read the stale live cursor (the renderer +hasn't yet caught up with our backspaces) and emit extra LEFT +arrows that push the terminal cursor past the start of input." (evil-ghostel-test--with-evil-buffer (setq-local ghostel--term t) (insert "word1 word2 word3") @@ -1439,15 +1654,12 @@ to move the point to the beginning of the line'." (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)) + (evil-ghostel-change 7 12 'exclusive nil nil)) (let* ((seq (nreverse keys-sent)) (left-count (cl-count "left" 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. + ;; Initial sync (6 lefts: col 17 → col 11 = end) + 5 backspaces. + ;; No second sync after backspaces. (should (= 6 left-count)) (should (= 5 bs-count))))))) @@ -1477,10 +1689,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 +1720,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 ;; ----------------------------------------------------------------------- @@ -1518,6 +1967,9 @@ sticks." evil-ghostel-test-escape-stay evil-ghostel-test-advice-on-insert evil-ghostel-test-advice-on-append + evil-ghostel-test-append-at-cursor-does-not-advance + evil-ghostel-test-append-after-cursor-moved-mid-input-advances + evil-ghostel-test-append-before-cursor-uses-vanilla evil-ghostel-test-advice-insert-line-sends-home evil-ghostel-test-advice-append-line-sends-end evil-ghostel-test-insert-line-multiline-syncs-row @@ -1525,6 +1977,16 @@ sticks." evil-ghostel-test-change-eol-syncs-cursor-to-point evil-ghostel-test-advice-no-op-outside-ghostel evil-ghostel-test-meaningful-length-strips-trailing + evil-ghostel-test-cursor-row-end-point-returns-eol + 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-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 @@ -1551,12 +2013,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 ()