From fdd369d44604b2fc6ac33e9771a3cb757c638221 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Fri, 1 May 2026 19:07:33 +0200 Subject: [PATCH] Route ESC to terminal in alt-screen apps under evil-ghostel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing ESC in evil insert state inside ghostel ran `evil-normal-state' even when the inner app (vim, less, htop, …) needed the keystroke. Add a buffer-local routing mode `auto'/`terminal'/`evil' controlled by `evil-ghostel-escape' and a toggle command with numeric prefix support. `auto' uses DECSET 1049 to decide. Terminal-bound ESC snaps to the live viewport like every other typed key; the evil-bound fallback lands on `evil-force-normal-state' when the user's `' binding is missing or a chord prefix. Fixes #215 --- extensions/evil-ghostel/evil-ghostel.el | 73 ++++++++++++ test/evil-ghostel-test.el | 151 +++++++++++++++++++++++- 2 files changed, 223 insertions(+), 1 deletion(-) diff --git a/extensions/evil-ghostel/evil-ghostel.el b/extensions/evil-ghostel/evil-ghostel.el index 209cd5d0..f1eed42d 100644 --- a/extensions/evil-ghostel/evil-ghostel.el +++ b/extensions/evil-ghostel/evil-ghostel.el @@ -56,6 +56,22 @@ last-writer-wins." (set-default-toplevel-value sym val) (evil-set-initial-state 'ghostel-mode val))) +(defcustom evil-ghostel-escape 'auto + "Where insert-state ESC is routed in ghostel buffers. + +`auto' — when the inner app is in alt-screen mode (DECSET 1049, + used by vim, less, htop, nvim, etc.) ESC is sent to the + terminal; otherwise evil's binding runs and switches to + normal state. +`terminal' — always send ESC to the terminal. +`evil' — always run evil's binding (ESC stays with evil). + +Sets the initial value of the buffer-local state. Use +\\[evil-ghostel-toggle-send-escape] to change it for the current buffer." + :type '(choice (const :tag "Auto (alt-screen heuristic)" auto) + (const :tag "Always to terminal" terminal) + (const :tag "Always to evil" evil))) + ;; Apply the current value at load. Covers the case where the user set ;; the variable with plain `setq' before loading the package — in that ;; path `defcustom' preserves the value without invoking `:set'. @@ -368,6 +384,62 @@ ORIG-FN is the advised `evil-redo' called with COUNT." (message "Redo not supported in terminal") (funcall orig-fn count))) +;; --------------------------------------------------------------------------- +;; ESC routing: terminal vs evil +;; --------------------------------------------------------------------------- + +(defvar-local evil-ghostel--escape-mode nil + "Buffer-local override for ESC routing. +Initialized from `evil-ghostel-escape' when the minor mode turns on. +Valid values: `auto', `terminal', `evil'.") + +(defconst evil-ghostel--escape-modes '(auto terminal evil) + "Cycle order for `evil-ghostel-toggle-send-escape'.") + +(defun evil-ghostel--escape () + "Dispatch insert-state ESC based on `evil-ghostel--escape-mode'. +Terminal-bound ESC is snapped to the live viewport like every other +typed key in `ghostel-mode-map'. When falling back to evil and the +user's `evil-insert-state-map' binding is missing or a chord prefix +\(e.g. `evil-escape''s `jk'), use `evil-force-normal-state' so the +keystroke is never silently dropped." + (interactive) + (let* ((mode evil-ghostel--escape-mode) + (to-terminal (or (eq mode 'terminal) + (and (eq mode 'auto) + ghostel--term + (ghostel--mode-enabled ghostel--term 1049))))) + (if to-terminal + (progn + (ghostel--snap-to-input) + (ghostel--send-encoded "escape" "")) + (let ((cmd (lookup-key evil-insert-state-map (kbd "")))) + (call-interactively (if (commandp cmd) cmd #'evil-force-normal-state)))))) + +(defun evil-ghostel-toggle-send-escape (&optional arg) + "Cycle or set the ESC routing mode for the current buffer. +Without ARG, cycle through `auto' → `terminal' → `evil' → `auto'. +With numeric prefix 1, set to `auto'; 2 to `terminal'; 3 to `evil'. +Other numeric prefixes signal a `user-error'. + +The mode is buffer-local; see `evil-ghostel-escape' for the default." + (interactive "P") + (let ((target + (if arg + (let ((n (prefix-numeric-value arg))) + (or (nth (1- n) evil-ghostel--escape-modes) + (user-error + "Invalid prefix %d; use 1 (auto), 2 (terminal), or 3 (evil)" + n))) + (let ((next (cdr (memq evil-ghostel--escape-mode + evil-ghostel--escape-modes)))) + (or (car next) (car evil-ghostel--escape-modes)))))) + (setq evil-ghostel--escape-mode target) + (message "evil-ghostel ESC mode: %s" target))) + +(evil-define-key* 'insert evil-ghostel-mode-map + (kbd "") #'evil-ghostel--escape) + ;; --------------------------------------------------------------------------- ;; Minor mode ;; --------------------------------------------------------------------------- @@ -381,6 +453,7 @@ state transitions." :keymap evil-ghostel-mode-map (if evil-ghostel-mode (progn + (setq evil-ghostel--escape-mode evil-ghostel-escape) (evil-ghostel--escape-stay) (add-hook 'evil-insert-state-entry-hook #'evil-ghostel--insert-state-entry nil t) diff --git a/test/evil-ghostel-test.el b/test/evil-ghostel-test.el index fa08dbb9..8132b823 100644 --- a/test/evil-ghostel-test.el +++ b/test/evil-ghostel-test.el @@ -736,6 +736,144 @@ Prevents up/down arrows being sent as history navigation." (evil-delete 1 6 'inclusive nil nil) (should (equal " world" (buffer-string))))) +;; ----------------------------------------------------------------------- +;; Test: ESC routing +;; ----------------------------------------------------------------------- + +(defmacro evil-ghostel-test--with-escape-stubs (alt-screen-p &rest body) + "Run BODY with `ghostel--mode-enabled' returning ALT-SCREEN-P for 1049 +and with `ghostel--send-encoded' captured into the local list `sent'." + (declare (indent 1) (debug t)) + `(let ((sent '())) + (cl-letf (((symbol-function 'ghostel--mode-enabled) + (lambda (_term mode) (and (= mode 1049) ,alt-screen-p))) + ((symbol-function 'ghostel--send-encoded) + (lambda (key mods &rest _) (push (cons key mods) sent)))) + (setq-local ghostel--term t) + ,@body))) + +(ert-deftest evil-ghostel-test-escape-init-from-defcustom () + "Activating the mode initializes `evil-ghostel--escape-mode' from defcustom." + (let ((evil-ghostel-escape 'terminal)) + (evil-ghostel-test--with-evil-buffer + (should (eq 'terminal evil-ghostel--escape-mode))))) + +(ert-deftest evil-ghostel-test-escape-mode-terminal-sends-pty () + "`terminal' mode always routes ESC to the PTY, regardless of alt-screen." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'terminal) + (evil-ghostel-test--with-escape-stubs nil + (evil-ghostel--escape) + (should (member '("escape" . "") sent))))) + +(ert-deftest evil-ghostel-test-escape-terminal-snaps-to-input () + "Terminal-bound ESC must snap the viewport like every other typed key. +Regression guard: dispatching directly via `ghostel--send-encoded' +bypasses the snap that `ghostel-mode-map''s `' route applies." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'terminal) + (let ((snapped 0)) + (cl-letf (((symbol-function 'ghostel--snap-to-input) + (lambda () (cl-incf snapped))) + ((symbol-function 'ghostel--send-encoded) + (lambda (&rest _)))) + (setq-local ghostel--term t) + (evil-ghostel--escape) + (should (= 1 snapped)))))) + +(ert-deftest evil-ghostel-test-escape-mode-evil-stays () + "`evil' mode never routes ESC to the PTY and triggers evil's binding." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'evil) + (evil-insert-state) + (evil-ghostel-test--with-escape-stubs t + (evil-ghostel--escape) + (should-not (member '("escape" . "") sent)) + (should-not (eq evil-state 'insert))))) + +(ert-deftest evil-ghostel-test-escape-auto-altscreen-sends-pty () + "`auto' mode routes ESC to the PTY when alt-screen (1049) is active." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'auto) + (evil-ghostel-test--with-escape-stubs t + (evil-ghostel--escape) + (should (member '("escape" . "") sent))))) + +(ert-deftest evil-ghostel-test-escape-auto-no-altscreen-stays () + "`auto' mode routes ESC to evil when alt-screen is not active." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'auto) + (evil-insert-state) + (evil-ghostel-test--with-escape-stubs nil + (evil-ghostel--escape) + (should-not (member '("escape" . "") sent)) + (should-not (eq evil-state 'insert))))) + +(ert-deftest evil-ghostel-test-escape-toggle-cycle () + "Calling toggle without a prefix cycles auto → terminal → evil → auto." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'auto) + (evil-ghostel-toggle-send-escape) + (should (eq 'terminal evil-ghostel--escape-mode)) + (evil-ghostel-toggle-send-escape) + (should (eq 'evil evil-ghostel--escape-mode)) + (evil-ghostel-toggle-send-escape) + (should (eq 'auto evil-ghostel--escape-mode)))) + +(ert-deftest evil-ghostel-test-escape-toggle-prefix-set () + "Numeric prefix sets the mode directly: 1=auto, 2=terminal, 3=evil." + (evil-ghostel-test--with-evil-buffer + (evil-ghostel-toggle-send-escape 2) + (should (eq 'terminal evil-ghostel--escape-mode)) + (evil-ghostel-toggle-send-escape 3) + (should (eq 'evil evil-ghostel--escape-mode)) + (evil-ghostel-toggle-send-escape 1) + (should (eq 'auto evil-ghostel--escape-mode)))) + +(ert-deftest evil-ghostel-test-escape-toggle-prefix-invalid () + "An out-of-range numeric prefix signals `user-error' and leaves state alone." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'auto) + (should-error (evil-ghostel-toggle-send-escape 7) :type 'user-error) + (should (eq 'auto evil-ghostel--escape-mode)))) + +(ert-deftest evil-ghostel-test-escape-mode-buffer-local () + "Setting the mode in one ghostel buffer must not leak into another." + (let ((buf-a (generate-new-buffer " *ghostel-a*")) + (buf-b (generate-new-buffer " *ghostel-b*"))) + (unwind-protect + (progn + (with-current-buffer buf-a + (ghostel-mode) + (setq-local ghostel--term-rows 100) + (evil-local-mode 1) + (evil-ghostel-mode 1) + (setq evil-ghostel--escape-mode 'terminal)) + (with-current-buffer buf-b + (ghostel-mode) + (setq-local ghostel--term-rows 100) + (evil-local-mode 1) + (evil-ghostel-mode 1) + (setq evil-ghostel--escape-mode 'evil)) + (with-current-buffer buf-a + (should (eq 'terminal evil-ghostel--escape-mode))) + (with-current-buffer buf-b + (should (eq 'evil evil-ghostel--escape-mode)))) + (kill-buffer buf-a) + (kill-buffer buf-b)))) + +(ert-deftest evil-ghostel-test-escape-evil-fallback-when-lookup-nil () + "When `lookup-key' yields no command (user rebound ESC to a chord +prefix), the dispatcher must fall back to `evil-force-normal-state' +rather than silently dropping the keystroke." + (evil-ghostel-test--with-evil-buffer + (setq evil-ghostel--escape-mode 'evil) + (evil-insert-state) + (cl-letf (((symbol-function 'lookup-key) + (lambda (&rest _) nil))) + (evil-ghostel--escape) + (should (eq 'normal evil-state))))) + ;; ----------------------------------------------------------------------- ;; Runner ;; ----------------------------------------------------------------------- @@ -757,7 +895,18 @@ Prevents up/down arrows being sent as history navigation." evil-ghostel-test-paste-after evil-ghostel-test-undo-sends-ctrl-underscore evil-ghostel-test-change-whole-line - evil-ghostel-test-delete-no-op-outside-ghostel) + evil-ghostel-test-delete-no-op-outside-ghostel + evil-ghostel-test-escape-init-from-defcustom + evil-ghostel-test-escape-mode-terminal-sends-pty + evil-ghostel-test-escape-terminal-snaps-to-input + evil-ghostel-test-escape-mode-evil-stays + evil-ghostel-test-escape-auto-altscreen-sends-pty + evil-ghostel-test-escape-auto-no-altscreen-stays + evil-ghostel-test-escape-toggle-cycle + evil-ghostel-test-escape-toggle-prefix-set + evil-ghostel-test-escape-toggle-prefix-invalid + evil-ghostel-test-escape-mode-buffer-local + evil-ghostel-test-escape-evil-fallback-when-lookup-nil) "Tests that require only Elisp (no native module).") (defun evil-ghostel-test-run-elisp ()