Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions extensions/evil-ghostel/evil-ghostel.el
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand Down Expand Up @@ -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 "<escape>"))))
(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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd imagine there's a scenario where you're in normal state in emacs but want ESC to be sent to the insert state in the nested emacs/vim or maybe vice versa. Not sure because we're dealing with 2 modal states that could be not synchronized.

Or maybe in a non normal/insert state (like visual/motion/etc?) state but wanting ESC to pass through.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I'm not an evil user I'm not 100% I understand what you mean, can you rephrase or make a suggestion for a fix?

btw (maybe it helps), you can always C-c C-q <esc> to send the next key to the terminal whatever it is ( in this case).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g. if you're in emacs (outer) and vim (inner) [start emacs -> start ghostel -> start vim inside ghostel

maybe using 'insert state is ok e.g. for the user to interact with ghostel, they need to be in insert state, which then triggers your function above

the cycling might be strange though

e.g. emacs+evil [normal state] -> go to insert state -> press i in vim to go to insert state -> press ESC. this cycles the ESC to the next thing.

But you want this instead:

emacs+evil [normal state] -> go to insert state in emacs -> press i in vim to go to insert state -> press ESC -> (normal state in vim) -> press i again to go to insert state in vim -> press ESC to go to normal state in vim -> press i again to go to insert state in vim -> and so on.

^ I think this case is more likely to happen.

If you do that though, you still need a way to get back to the outer emacs (e.g. to get back to normal state in emacs) but once the inner vim is taking ESC inputs, it really won't know which ESC you want to send to so you can't do it automagically.

suggestion for a fix?

No suggestion, the way we do it in vterm is kind of a "this is too complicated, just let the user decide when they want what". If there's a more clever way to handle this, I'm all for it.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emacs+evil [normal state] -> go to insert state in emacs -> press i in vim to go to insert state -> press ESC -> (normal state in vim) -> press i again to go to insert state in vim -> press ESC to go to normal state in vim -> press i again to go to insert state in vim -> and so on.

^ I think this case is more likely to happen.

If you do that though, you still need a way to get back to the outer emacs (e.g. to get back to normal state in emacs) but once the inner vim is taking ESC inputs, it really won't know which ESC you want to send to so you can't do it automagically.

But doesn't the auto-mode kinda address that in that is sends the ESC so long to vim as long as vim is open. You close it to type something in the shell, ESC gets routed to evil?!

And if you want your first case or not have ESC forwardet to the terminal when an alt-screen app is running, you have to manually change the routing (like in your vterm solution right now)?!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to leave the window/leave vim running but still be able to get back to Normal state in emacs. I think this is a relatively common use case so won't describe specific examples. :)

And if you want your first case or not have ESC forwardet to the terminal when an alt-screen app is running, you have to manually change the routing (like in your vterm solution right now)?!

Yeah, I think it's a more manual/intentional step.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to leave the window/leave vim running but still be able to get back to Normal state in emacs. I think this is a relatively common use case so won't describe specific examples. :)

ok.
But anyway, should we merge this PR as it provides at least a workaround (and with the auto-mode hopefully even a slightly better hack than vterm)?
(and this might be me not using it, but if you just want to get into normal state emacs, you
can of course just bind evil-force-normal-state so something like C-c esc or whatever.)

And if you or some other evil user has a better idea how that "workflow" could be implemented I'm open for issues or PRs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, sounds good.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks.

And happy to discuss any ideas/PRs, especially evil related as I don't test this integration very much.

(kbd "<escape>") #'evil-ghostel--escape)

;; ---------------------------------------------------------------------------
;; Minor mode
;; ---------------------------------------------------------------------------
Expand All @@ -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)
Expand Down
151 changes: 150 additions & 1 deletion test/evil-ghostel-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<escape>' 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
;; -----------------------------------------------------------------------
Expand All @@ -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 ()
Expand Down
Loading