Modal editing without mode switching: single-letter commands that appear exactly where you would never type text.
In Emacs, frequently used commands are usually bound to key sequences with
prefix keys such as C-c and C-x. That is not as fast as the
single-letter keys of modal editing in the vi tradition, which is why many
people use evil-mode or meow. But modal editing has a cost of its own:
you constantly have to switch modes by hand.
auto-modal takes a third path: the editing state is derived from where point is. You register keybindings consisting of a key, a major mode, a predicate function and a command. Whenever point satisfies a predicate — say, at the beginning of a non-empty line, or right next to a parenthesis in Lisp code — the buffer automatically enters control state and your single-letter keys run commands. Everywhere else the buffer stays in insert state and behaves exactly like ordinary Emacs. The cursor shape and color follow the state, so you always know where you are. You never switch modes; you just move point, and the mode follows.
- Requirements
- Installation
- Quick start
- Concepts
- API reference
- The help key
- Logging
- The example configuration
- Writing your own predicates
- Performance notes
- Migrating from 0.x
- Development
- License
- Emacs 29.1 or later.
- No external packages required. (Two bindings in the optional example
configuration reference
avycommands; without avy they register fine and signal a clear "not defined" error if pressed.)
Install auto-modal directly from the repository.
With use-package and :vc (Emacs 30+):
(use-package auto-modal
:vc (:url "https://github.com/Kinneyzhang/auto-modal" :rev :newest))With straight.el:
(straight-use-package
'(auto-modal :type git :host github :repo "Kinneyzhang/auto-modal"))Manually:
;; git clone https://github.com/Kinneyzhang/auto-modal ~/path/to/auto-modal
(add-to-list 'load-path "~/path/to/auto-modal")
(require 'auto-modal)(require 'auto-modal)
;; A predicate: point at the beginning of a non-empty line.
(defun my/bolp ()
(and (bolp) (not (looking-at-p "^$"))))
;; When the predicate holds, "j" and "k" move between lines
;; and "SPC" lets you type at that position.
(auto-modal-bind-key "j" 'global 'my/bolp 'next-line)
(auto-modal-bind-key "k" 'global 'my/bolp 'previous-line)
(auto-modal-bind-key "SPC" 'global 'my/bolp 'auto-modal-enable-insert)
;; Turn it on everywhere.
(global-auto-modal-mode 1)Now move point to the beginning of any non-empty line: the cursor becomes a
box — control state. Press j/k to move by lines, ? to see every key
that currently does something. Move point anywhere else (or press SPC and
start typing) and the buffer is plain Emacs again.
Prefer a ready-made setup? Load the bundled example configuration and skip ahead to its manual:
(require 'auto-modal-config)
(global-auto-modal-mode 1)With auto-modal-mode enabled, a buffer is always in one of two states:
| State | When | Keys | Default cursor |
|---|---|---|---|
| Control | point satisfies at least one registered predicate | registered keys run commands; other self-inserting keys are suppressed | box |
| Insert | everywhere else | ordinary Emacs | bar |
The state is re-computed after every command. There is nothing to toggle by hand (although you can: see Typing at a trigger position).
Non-letter keys (RET, C-…, M-…, function keys …) are never touched:
in control state only self-inserting keys are suppressed, and only the
keys you registered run auto-modal commands.
A predicate is a function of no arguments, called with point at the
position to test. It should be cheap and side-effect free — predicates run
from post-command-hook. The predicate t means "always" and turns
auto-modal into classic hand-switched modal editing (see
the vi emulation).
Good trigger positions are places where you would rarely type text:
- the beginning of a non-empty line (
(and (bolp) (not (looking-at-p "^$")))), - next to a parenthesis in Lisp code,
- on an active region (
use-region-p), - on an org heading star, a Markdown
#, a dired line…
Each binding names a major mode. It applies in that mode and every mode
derived from it. The special symbol global (an alias for
fundamental-mode) applies everywhere.
(auto-modal-bind-key "j" 'global 'my/bolp 'next-line)
(auto-modal-bind-key "j" 'prog-mode 'my/bolp 'my/next-defun)
(auto-modal-bind-key "j" 'emacs-lisp-mode 'my/bolp 'my/next-sexp)With all three registered, pressing j at the beginning of a line runs
my/next-sexp in emacs-lisp-mode buffers (closest mode wins),
my/next-defun in every other programming mode, and next-line everywhere
else.
When you press a registered key in control state, auto-modal picks the binding to run:
- Among all bindings for that key whose mode applies to the buffer and whose predicate is satisfied, the one whose mode is closest to the current major mode wins. On a tie, the most recently added one wins.
- If the winning binding was registered without
override-pand the major mode's own keymap binds the key, the major mode's command runs instead. This keeps special modes like dired or magit intact: their letter keys keep doing what they always did. Pass a non-nil fifth argument (override-p) toauto-modal-bind-keyto win over the major mode. - If no binding's predicate is satisfied (the buffer is in control state because of some other key's predicate), the key does whatever it would do without auto-modal — usually self-insert.
Sometimes you do need to type text exactly at a trigger position. The
command auto-modal-enable-insert switches to insert state for one
command: bind it to a key (SPC works well), press it, and type away. The
escape hatch is consumed at your next keystroke; as soon as point stops
satisfying the predicates the buffer returns to automatic switching — in
practice you type, move on, and never think about it.
(auto-modal-bind-key KEY-NAME MODE PREDICATE FUNCTION-ARGS &optional OVERRIDE-P)| Argument | Meaning |
|---|---|
KEY-NAME |
a key string in kbd format: "j", "SPC", "<" … |
MODE |
a major mode symbol, or global for every buffer |
PREDICATE |
a nullary function, or t for "always" |
FUNCTION-ARGS |
a function — called interactively when it is a command — or a list (FUNCTION ARGS...), applied as (apply FUNCTION ARGS) |
OVERRIDE-P |
non-nil: win over the major mode's own binding of the key |
(auto-modal-bind-key "o" 'global 'my/bolp '(other-window 1)) ; with arguments
(auto-modal-bind-key "j" 'dired-mode t 'dired-next-line t) ; override dired's own "j"PREDICATE and FUNCTION may be symbols that are not defined yet, so you
can bind commands of packages that load lazily; an unsatisfied (undefined)
predicate simply never triggers, and dispatching to an undefined function
signals a clear error.
The key named by auto-modal-help-key (? by default) is reserved and
cannot be registered.
| Function | Behavior |
|---|---|
(auto-modal-unbind-key KEY MODE PRED FUNCTION-ARGS &optional OVERRIDE-P) |
removes the binding registered with exactly these arguments |
(auto-modal-unbind KEY &optional MODE PREDICATE) |
removes every binding of KEY, optionally restricted to a mode and/or predicate; also a command |
(auto-modal-unbind-with-predicate PREDICATE) |
removes every binding using PREDICATE |
| Symbol | Kind | Description |
|---|---|---|
auto-modal-mode |
minor mode | automatic modal switching in the current buffer |
global-auto-modal-mode |
global minor mode | enables auto-modal-mode in every buffer (except minibuffers and internal buffers) |
auto-modal-list-keybinds |
command | list all registered bindings in a table |
auto-modal-keyhint-show |
command | show the keys applicable at point in the echo area |
auto-modal-enable-insert |
command | one-shot switch to insert state (bind it to a key) |
auto-modal-triggered-p |
function | non-nil when point satisfies some predicate |
auto-modal-set-cursor |
command | recompute state and cursor of the current buffer |
auto-modal-all-keys |
function | all registered key names |
auto-modal-data |
variable | the list of auto-modal-keybind structs |
M-x customize-group RET auto-modal RET, or set from Lisp:
| Option | Default | Meaning |
|---|---|---|
auto-modal-control-cursor-type |
box |
cursor-type in control state; default = your default cursor |
auto-modal-insert-cursor-type |
bar |
cursor-type in insert state |
auto-modal-control-cursor-color |
nil |
cursor color in control state, see below |
auto-modal-insert-cursor-color |
nil |
cursor color in insert state |
auto-modal-help-key |
"?" |
reserved key showing the keyhint |
auto-modal-enable-keyhint |
nil |
show the keyhint automatically on entering control state |
auto-modal-enable-log |
nil |
log dispatched keys to *Auto-modal-log* |
auto-modal-log-max-number |
40 |
log entries kept |
The two color options accept four forms:
nil ; the theme's default cursor color
'error ; a face: use its foreground color
"#ff6c6b" ; a color string
'("#005f87" . "#87d7ff") ; (LIGHT . DARK), picked by backgroundThe (LIGHT . DARK) cons is re-evaluated when the theme changes, so one
setting works for both light and dark themes.
| Hook | Run |
|---|---|
auto-modal-turn-on-hook |
after auto-modal-mode is enabled in a buffer |
auto-modal-turn-off-hook |
after it is disabled |
The whole point of auto-modal is that active keys depend on where point is
— so discoverability matters. In control state, press ?
(auto-modal-help-key) to see every key applicable at point:
SPC → auto-modal-enable-insert j → auto-modal-next-line k → auto-modal-previous-line …
Set auto-modal-enable-keyhint to t to show this automatically every
time a buffer enters control state.
Set auto-modal-enable-log to t and every dispatched key with its
command is recorded in the *Auto-modal-log* buffer (trimmed to
auto-modal-log-max-number entries). Useful to understand which binding
won when several could apply.
auto-modal-config.el ships a complete working setup — the author's own.
Load it as a starting point, or copy the parts you like:
(require 'auto-modal-config)
(global-auto-modal-mode 1)Predicate: auto-modal-bolp — point at the beginning of a non-empty line.
| Key | Command |
|---|---|
j / k |
next / previous non-empty line (auto-modal-next-line / auto-modal-previous-line) |
l |
avy-goto-line |
c |
avy-goto-char-timer |
o |
other window |
v |
set-mark-command |
< / > |
backward / forward page |
z |
read-only-mode |
SPC |
type here (one-shot insert) |
Predicate: use-region-p — any active region, anywhere in the line.
| Key | Command |
|---|---|
u |
upcase-dwim |
d |
downcase-dwim |
c |
kill-ring-save |
Note how c is bound twice — once for regions, once for beginnings of
lines. Predicates disambiguate: whichever one is satisfied wins. When
both are satisfied at once (region active with point at bol), the
region binding wins because it is registered later — ties go to the
most recently added binding.
Predicate: auto-modal-sexp-around-paren-p — point next to a paren in
emacs-lisp-mode (parens inside strings and comments don't count).
| Key | Command | Moves |
|---|---|---|
f / b |
auto-modal-sexp-forward / -backward |
next/previous paren of the same side |
j / k |
auto-modal-sexp-down / -up |
next/previous paren at the same depth |
i / o |
auto-modal-sexp-into / -outside |
one level deeper / out to the enclosing expression |
s |
auto-modal-sexp-balance |
the balancing paren |
n |
auto-modal-sexp-newline-paren |
insert () on a new line, point inside |
; |
auto-modal-sexp-comment |
comment the expression out |
SPC |
type here (one-shot insert) |
With the predicate t, auto-modal degenerates into classic modal editing —
here is vi in thirty lines. auto-modal-vi-mode gives you h/j/k/l/w/b
in every buffer that has auto-modal-mode enabled (enable
global-auto-modal-mode to get them everywhere), i to insert,
<escape> to toggle back:
(auto-modal-vi-mode 1)The key list lives in auto-modal-vi-keybinds; extend it before enabling
the mode. An entry may carry arguments: ("z" forward-line 2). Note that
on terminal frames ESC acts as a prefix key, so bind a different toggle
key there.
A predicate runs after every command, so keep it cheap and read-only. Some ideas:
;; On the stars of an org heading.
(defun my/org-headp ()
(and (org-at-heading-p) (< (point) (+ (line-beginning-position)
(org-current-level)))))
(auto-modal-bind-key "n" 'org-mode 'my/org-headp 'org-next-visible-heading)
(auto-modal-bind-key "p" 'org-mode 'my/org-headp 'org-previous-visible-heading)
;; At the end of a line, one key to jump to end of buffer.
(auto-modal-bind-key "e" 'global 'eolp 'end-of-buffer)
;; In an empty line of a programming buffer, quick comment.
(defun my/empty-linep () (looking-at-p "^$"))
(auto-modal-bind-key ";" 'prog-mode 'my/empty-linep 'comment-dwim)Rules of thumb:
- Prefer positions where typing is rare; that is what makes the automatic switching invisible.
- Return non-nil fast; you can rely on
syntax-ppssfor syntax context. - The same key with different predicates is fine — see
cabove.
The implementation was written to keep the per-keystroke overhead small:
- Trigger state is computed once per command from a single
post-command-hookfunction, short-circuiting at the first satisfied predicate. - Major mode inheritance distances are cached in a hash table.
- The global hooks are installed while at least one buffer has
auto-modal-modeenabled, and removed with the last one. In buffers without the mode the hook exits after one buffer-local check.
The practical cost is your predicates. If one of them is expensive, scope it to the narrowest major mode possible so other buffers never run it.
Version 1.0.0 is a full rewrite of the internals; the documented API is
unchanged, but some internal symbols are gone or renamed. Configurations
that only used auto-modal-bind-key / auto-modal-unbind-key /
auto-modal-unbind-with-predicate and the cursor options work unchanged.
| Old | New |
|---|---|
suppress-key-mode, suppress-key-mode-map |
auto-modal-control-mode, auto-modal-control-mode-map (managed automatically) |
auto-modal-is-triggerp |
auto-modal-triggered-p (obsolete alias kept) |
auto-modal-mode-turn-on |
internal (obsolete alias kept) |
sexp-forward, sexp-down, … (config) |
auto-modal-sexp-forward, auto-modal-sexp-down, … |
auto-modal-vi-mode-toogle (config) |
auto-modal-vi-mode-toggle (obsolete alias kept) |
major-mode-chain, major-mode-derived-p, background-mode-change-*, auto-modal-functions-data, auto-modal-trigger-functions, auto-modal-key-command, auto-modal-original-command, auto-modal-switch-to-insert/control |
removed (internal) |
depends on bind-key |
no external dependencies |
See CHANGELOG.md for the complete list, including the bug fixes that motivated the rewrite.
make compile # byte-compile with warnings as errors
make checkdoc # docstring lint
make test # run the ERT suite
make all # everythingCI runs the same three steps on Emacs 29.4 and 30.1. Bug reports and pull
requests are welcome — please keep make all green.
GPL-3.0-or-later. See the license notices in the source files.