Moving to vanilla Emacs

Feb 2026 - 23 min read

Introduction

After years of daily-driving Doom Emacs, I wrote about my Doom configuration not long ago. That article was meant to be a snapshot of a mature setup, but writing it forced me to confront something I had been avoiding: I no longer understood large parts of my own editor. Doom had become a black box with my customizations bolted on top.

So I started over. Not with another distribution, but with a blank init.el and a clear goal: build something minimal, fast, and entirely mine. This post walks through the interesting parts of my vanilla Emacs config (5570fb3), explaining the design decisions and what I kept, discarded, or improved from Doom.

my vanilla Emacs setup

I. Why leave Doom

Doom Emacs is excellent software. It gave me a productive Emacs environment from day one, and its module system is genuinely well-designed. Doom is already lightweight compared to other distributions, and its modular config makes it even lighter, you only enable what you actually use.

But after years of customizing, I realized the issue was not with Doom. It was with me. I love tweaking my config, shaping it around my personal workflows, and endlessly refining how things work. My config had grown into hundreds of lines of overrides, workarounds, and hooks layered on top of Doom’s defaults. Disabled packages, patched keybindings, suppressed features. I had accumulated so much custom code that I was effectively maintaining my own framework on top of someone else’s framework. At that point, the abstraction was not helping anymore.

Doom is probably better suited for someone who wants something clean, fast, and productive out of the box, without spending hours tuning every detail.

What pushed me to start over was a desire for understanding. I wanted to know exactly what every line in my config does, why it is there, and what happens when I remove it. With Doom, debugging meant tracing through layers of macros, deferred loading, and module interactions that I did not write. With a hand-rolled config, the answer is always in my own code.

The other factor was Emacs itself catching up. Emacs 29+ ships with Eglot, tree-sitter, project.el, and use-package built in. Many things that justified a framework in 2020 are now just a (use-package ...) block away. In my experience, the gap between “vanilla Emacs” and “a productive setup” has shrunk a lot.

The goal was not to replicate Doom. It was to strip everything back to only what I need, nothing more, and understand every bit of it.

II. Config structure

The configuration lives in ~/.emacs.d/ with a flat module structure:

  • early-init.el, performance settings, fonts, GUI stripping
  • init.el, bootstrap, core settings, module loading
  • lisp/, one file per concern, all prefixed sw- (smallwat3r)
  • elpaca-lock.el, package lock file for reproducibility

Each module in lisp/ handles a single concern:

(require 'sw-theme)
(require 'sw-evil)
(require 'sw-modeline)
(require 'sw-completion)
; ...
copy

This is deliberately simpler than Doom’s module system. No config.el vs packages.el split, no modulep! guards, no autoload directory. Each file is a plain Emacs Lisp module that provides its own feature. Every module in that list is something I actually use.

All custom functions and variables share the sw- prefix, keeping the namespace clean and greppable.

III. Elpaca over Straight.el

Doom uses Straight.el for package management, wrapped in its own CLI (doom sync, doom upgrade, etc.) for managing dependencies and rebuilding packages. It works, but Straight.el is synchronous and the whole system is highly coupled.

I switched to Elpaca, a more modern package manager that installs packages asynchronously and has a simpler mental model:

(defvar elpaca-installer-version 0.11)
(defvar elpaca-directory
  (expand-file-name "elpaca/" user-emacs-directory))
(defvar elpaca-lock-file
  (expand-file-name "elpaca-lock.el" user-emacs-directory))
copy

The lock file (elpaca-lock.el) pins every package to a specific commit, so a fresh clone gets the exact same versions. Doom and Straight.el also provide version pinning, but Elpaca feels more forward-thinking in its approach.

Elpaca integrates with use-package through a single mode toggle:

(elpaca elpaca-use-package
  (elpaca-use-package-mode))
(elpaca-wait)

(setq use-package-always-defer t
      use-package-always-ensure t
      use-package-expand-minimally t)
copy

The use-package-always-defer t is important. It means every package is lazy-loaded by default, unless explicitly marked :demand t. Combined with the custom hooks described below, this keeps startup fast without manual :defer annotations on every block.

IV. Startup performance

Doom already has excellent defaults for startup performance, and is one of the best frameworks in this regard. But with a hand-rolled config, you have full control over the loading sequence and can be surgical about what runs when.

My current startup time is around 0.3s without the daemon (Fedora 43), which makes it feel almost instant, and even faster with the daemon.

a) GC and file handler tricks

The early-init.el sets gc-cons-threshold to the maximum value, which prevents Emacs from running garbage collection (GC) while loading init files. GC pauses during startup are wasted time since all the allocated memory is still in use. It also temporarily clears file-name-handler-alist, the list of regex handlers Emacs checks on every file operation (for compressed files, TRAMP, etc.). During init, none of these are needed, and skipping the regex matching on every load and require adds up:

(setq gc-cons-threshold most-positive-fixnum
      gc-cons-percentage 0.6)

(defvar sw-file-name-handler-alist file-name-handler-alist)
(setq file-name-handler-alist nil)
(add-hook 'emacs-startup-hook
          (lambda ()
            (setq file-name-handler-alist
                  sw-file-name-handler-alist)))
copy

After startup, we need to restore sensible GC behavior, but Emacs’ default threshold (800KB) is too aggressive for a modern config and causes frequent pauses during normal use. GCMH solves this by raising the threshold while you are active (64MB here, high enough that GC rarely fires mid-keystroke) and only collecting when Emacs is idle. The auto delay adapts based on how long GC actually takes, scaled by the delay factor:

(use-package gcmh
  :hook (sw-first-buffer . gcmh-mode)
  :custom
  (gcmh-idle-delay 'auto)
  (gcmh-auto-idle-delay-factor 10)
  (gcmh-high-cons-threshold (* 64 1024 1024)))
copy

The result is that you never notice GC happening. It runs when you pause to think, not when you are typing or scrolling.

b) Deferred initialization hooks

This idea is borrowed from Doom, which has similar hooks for staging initialization. Three custom hooks defer work until it is actually needed:

(defvar sw-first-input-hook nil
  "Transient hook run before the first user input.")

(defvar sw-first-file-hook nil
  "Transient hook run before the first file is opened.")

(defvar sw-first-buffer-hook nil
  "Transient hook run before the first buffer switch.")
copy

Each fires once and then removes itself. The triggers are registered after startup:

(add-hook 'emacs-startup-hook
          (lambda ()
            (add-hook 'pre-command-hook #'sw--run-first-input)
            (add-hook 'find-file-hook #'sw--run-first-file)
            (add-hook 'window-buffer-change-functions
                      #'sw--run-first-buffer)))
copy

Most packages hook into these rather than loading eagerly. For example, Vertico defers until first input, diff-hl until first file, show-paren until first buffer:

(use-package vertico
  :hook (sw-first-input . vertico-mode))

(use-package diff-hl
  :hook (sw-first-file . global-diff-hl-mode))

(add-hook 'sw-first-buffer-hook #'show-paren-mode)
copy

This keeps the init sequence tight. Emacs starts, shows the dashboard, and only begins loading the completion stack, syntax highlighting, and other heavy features when you actually interact with it.

c) Native compilation and rendering

A few other settings help with runtime performance. Native compilation (native-comp-jit-compilation) compiles Elisp to native code in the background as packages are loaded, making subsequent runs faster. Disabling bidirectional text reordering removes per-line overhead that is unnecessary for left-to-right languages. And skipping fontification during rapid input prevents Emacs from re-highlighting syntax while you are typing, which keeps things smooth in large files:

;; JIT native compilation, silent warnings
(setq native-comp-async-report-warnings-errors 'silent
      native-comp-jit-compilation t)

;; Disable bidirectional text for left-to-right languages
(setq-default bidi-display-reordering 'left-to-right
              bidi-paragraph-direction 'left-to-right)
(setq bidi-inhibit-bpa t)

;; Skip fontification during rapid input
(setq redisplay-skip-fontification-on-input t)
copy

V. Visual philosophy

My philosophy here is: show nothing unless it earns its place. No fringe, no scroll bars, no menu bar, no tool bar. The frame is maximized with 1px window dividers and nothing else. This might not be for everyone, but it helps me focus on only one thing: the actual code.

a) Theme and fonts

I use creamy, the same warm, minimal theme from my Doom setup. It strips away excess color and emphasizes only the essentials. For late-night sessions, warm-mode adds a subtle warm tint and dims colors. It is a package I wrote recently that shifts RGB values at runtime, reducing blue light and dimming brightness across all faces. It works with any theme without requiring theme-specific configuration:

(use-package warm-mode
  :ensure (:host github :repo "smallwat3r/emacs-warm-mode")
  :custom
  (warm-mode-warmth 0.25)
  (warm-mode-dim 0.9))
copy

Here is what it looks like, the creamy theme with warm-mode applied.

Creamy theme with warm-mode

The font is Monaco (or MonacoB on Linux). It is the font I originally started programming with, and I keep coming back to it. Simple, compact, easy on the eye. Font setup is platform-aware and runs early to prevent frame flickering:

(defvar sw-font-family (if sw-is-mac "Monaco" "MonacoB"))
(defvar sw-font-size (if sw-is-mac 13 10))

(set-face-attribute 'default nil
                    :family sw-font-family
                    :height (* sw-font-size 10))
(push `(font . ,(format "%s-%d" sw-font-family sw-font-size))
      default-frame-alist)
copy

b) The modeline, or lack of it

This is probably the most opinionated part of the config. The traditional mode-line is gone. Instead, buffer information lives in the echo area, right-aligned alongside any current message.

mini-modeline does something similar, but I wanted my own implementation. It is only a handful of lines, I understand every part of it, and it does exactly what I need with nothing extra:

(defun sw--update-echo-area ()
  "Update echo area with buffer info."
  (unless (active-minibuffer-window)
    (let* ((info (format "%s%s  %s %s,%d"
                         (if (buffer-modified-p) "** " "")
                         (buffer-name)
                         (format-mode-line "%p")
                         (format-mode-line "%l")
                         (current-column)))
           (cur (or (current-message) ""))
           (padding (- (frame-width)
                       (length cur) (length info) 1))
           (msg (concat cur
                        (make-string (max 1 padding) ?\s)
                        info))
           (message-log-max nil))
      (message "%s" msg))))

(add-hook 'post-command-hook #'sw--update-echo-area)
copy

The display shows: modified marker (**), buffer name, scroll position, line and column. Simple, always visible, and it reclaims the entire mode-line area.

Modeline information displayed in the minibuffer

When there is only one window, the mode-line is hidden entirely. When multiple windows exist, it becomes a thin colored bar, red for active, dark gray for inactive, just enough to distinguish which window has focus:

(defun sw--update-mode-line-visibility ()
  "Show mode-line only when frame has multiple windows."
  (let ((fmt (if (> (count-windows) 1) " " nil)))
    (dolist (win (window-list))
      (with-selected-window win
        (unless (eq mode-line-format fmt)
          (setq mode-line-format fmt))))))

(set-face-attribute 'mode-line nil
                    :background "#e63946" :box nil :height 0.1)
(set-face-attribute 'mode-line-inactive nil
                    :background "#333333" :box nil :height 0.1)
copy

c) Dashboard

The dashboard is minimal, centered text showing the Emacs version, OS, mode (daemon or standalone), and load time. It re-renders on window resize to stay centered:

(defun sw-dashboard-render ()
  "Render the dashboard content in the current buffer."
  (with-silent-modifications
    (erase-buffer)
    (let* ((width (window-body-width))
           (height (window-body-height))
           (top-padding (max 0 (/ (- height 7) 2)))
           (center (lambda (s)
                     (insert (make-string
                              (max 0 (/ (- width (length s)) 2))
                              ?\s) s "\n")))
           (load-time (float-time
                       (time-subtract after-init-time
                                      before-init-time))))
      (insert (make-string top-padding ?\n))
      (funcall center "smallwat3r's Emacs")
      (insert "\n\n")
      (funcall center (format "Emacs %s" emacs-version))
      (funcall center sw-dashboard--os-version)
      (funcall center
               (if (daemonp) "Mode: Daemon" "Mode: Standalone"))
      (insert "\n")
      (funcall center (format "Loaded in %.2fs" load-time)))))
copy

The buffer is protected from accidental kills and acts as a stable home base, similar to Doom’s dashboard but without the ASCII art or recent file list.

Minimal dashboard showing Emacs version and load time

VI. Completion stack

The completion framework follows the modern Emacs approach: Vertico for the minibuffer, Corfu for in-buffer, Consult for enhanced search, and Orderless for flexible matching. This replaces Doom’s Vertico/Corfu modules with direct configuration.

(use-package vertico
  :hook (sw-first-input . vertico-mode)
  :custom
  (vertico-count 10)
  (vertico-resize nil)
  (vertico-cycle t))

(use-package corfu
  :hook (sw-first-input . global-corfu-mode)
  :custom
  (corfu-count 5)
  (corfu-auto t)
  (corfu-auto-delay 0.2)
  (corfu-auto-prefix 2)
  (corfu-cycle t)
  (corfu-preselect 'prompt))

(use-package orderless
  :custom
  (completion-styles '(orderless basic))
  :config
  (setq orderless-matching-styles
        '(orderless-literal orderless-regexp orderless-flex)))
copy

Consult uses ripgrep and fd as backends, with async settings tuned for responsiveness:

(use-package consult
  :custom
  (consult-async-min-input 2)
  (consult-async-refresh-delay 0.15)
  (consult-async-input-throttle 0.2)
  (consult-async-input-debounce 0.1)
  :config
  (setq consult-ripgrep-args
        (concat "rg --null --line-buffered --color=never "
                "--max-columns=1000 --path-separator / "
                "--smart-case --no-heading --line-number "
                "--hidden --glob !.git"))
  (setq consult-fd-args
        `(,(if (executable-find "fdfind") "fdfind" "fd")
          "--color=never" "--hidden" "--exclude" ".git")))
copy

Cape extends completion-at-point with dabbrev, file, and keyword sources. The order matters, add-to-list pushes to the front, so the last added runs first:

(use-package cape
  :after corfu
  :config
  (add-to-list 'completion-at-point-functions #'cape-keyword)
  (add-to-list 'completion-at-point-functions #'cape-file)
  (add-to-list 'completion-at-point-functions #'cape-dabbrev))
copy

VII. Project management with project.el

Doom uses Projectile. It works well, but project.el has been built into Emacs since version 28 and integrates natively with xref, compilation buffers, and Consult. One less dependency.

(use-package project
  :ensure nil
  :demand t
  :custom
  (project-list-file
    (expand-file-name "projects" user-emacs-directory))
  (project-switch-commands #'project-find-file)
  :config
  (setq project-vc-extra-root-markers
        '(".project" ".projectile" "Cargo.toml" "go.mod"
          "package.json" "pyproject.toml" "setup.py"
          "Makefile" ".git")))
copy

Setting project-switch-commands to #'project-find-file means switching projects goes directly to the file finder instead of showing the project menu. One less keypress.

Auto-discovery scans a few base directories on demand:

(defvar sw-project-directories
  '("~/code" "~/work" "~/dotfiles"))

(defun sw-project-discover ()
  "Scan `sw-project-directories' for git repos."
  (interactive)
  (dolist (dir sw-project-directories)
    (let ((expanded (expand-file-name dir)))
      (when (file-directory-p expanded)
        (project-remember-projects-under expanded nil)))))
copy

Helper functions wrap Consult for project-scoped operations:

(defun sw-project-find-file ()
  "Find file in current project using fd."
  (interactive)
  (consult-fd (sw-project-root-or-default)))

(defun sw-consult-ripgrep-project ()
  "Search in current project with ripgrep."
  (interactive)
  (consult-ripgrep (sw-project-root-or-default)))
copy

VIII. Terminal with Eat

Doom offers multiple terminal options, but I stuck with vterm for its performance. It works well, but requires compiling a C library and has quirks with Evil mode. I switched to Eat, a pure Elisp terminal emulator that is easier to extend. Eat has its own quirks and is a little less fast than vterm, but being pure Elisp means no C library to compile and simpler integration with the rest of Emacs. Both support TRAMP.

a) Evil integration

The main challenge with any terminal in Evil mode is the state switching. In normal state, you want Emacs keybindings for scrolling and navigation. In insert state, keystrokes should go to the shell. Eat handles this with mode switching:

(defun sw-eat-evil-insert-enter ()
  "Switch to semi-char mode when entering insert state."
  (when (and (derived-mode-p 'eat-mode)
             (not (eq eat--input-mode 'semi-char)))
    (eat-semi-char-mode)))

(defun sw-eat-evil-insert-exit ()
  "Switch to emacs mode when exiting insert state."
  (when (and (derived-mode-p 'eat-mode)
             (not (eq eat--input-mode 'emacs)))
    (eat-emacs-mode)))

(add-hook 'evil-insert-state-entry-hook
          #'sw-eat-evil-insert-enter)
(add-hook 'evil-insert-state-exit-hook
          #'sw-eat-evil-insert-exit)
copy

Normal state gets Vim-style navigation, p to paste from kill ring, dd to send C-c, and RET to enter insert:

(evil-define-key* 'normal eat-mode-map
  (kbd "B")   #'beginning-of-line
  (kbd "E")   #'end-of-line
  (kbd "G")   #'end-of-buffer
  (kbd "gg")  #'beginning-of-buffer
  (kbd "C-u") #'evil-scroll-up
  (kbd "C-d") #'evil-scroll-down
  (kbd "RET") #'evil-insert-state
  (kbd "dd")  #'sw-eat-interrupt
  (kbd "p")   #'sw-eat-yank)
copy

b) ZSH history integration

A custom command reads ~/.zsh_history and presents completions inside Eat. If there is existing input on the command line, it becomes a prefix filter via ^pattern:

(defun sw-eat-zsh-history-pick ()
  "Prompt from zsh history and insert into eat.
Current input becomes a prefix filter (^pattern)."
  (interactive)
  (let* ((history (sw-zsh-history-candidates))
         (input (sw-eat--current-input))
         (choice (completing-read
                  "zsh history: " collection nil nil
                  (if input (concat "^" input) ""))))
    (when input
      (eat-term-send-string eat-terminal "\C-u"))
    (eat-term-send-string eat-terminal choice)))
copy

Bound to C-, in both normal and insert states.

c) TRAMP integration

When opening an Eat buffer on a remote TRAMP connection, a hook injects an e shell function that opens remote files in local Emacs buffers. The approach is similar to what I had with vterm in Doom, but adapted for Eat’s message handler protocol:

(defun sw-eat-setup-tramp (proc)
  "Configure eat for TRAMP: rename buffer, inject `e` opener."
  (when-let* ((buf (process-buffer proc))
              (_ (file-remote-p default-directory))
              (tramp-prefix (file-remote-p default-directory)))
    (with-current-buffer buf
      (rename-buffer (format "*eat@%s*" tramp-prefix) t)
      ;; Poll for prompt readiness, then send initialization
      (let ((timer nil) (attempts 0))
        (setq timer
              (run-with-timer 0.1 0.1
               (lambda ()
                 (setq attempts (1+ attempts))
                 (if (or sw-eat-tramp-initialized
                         (>= attempts 20)
                         (not (process-live-p proc)))
                     (cancel-timer timer)
                   (sw-eat--try-send-tramp-init
                    proc tramp-prefix)))))))))
copy

The initialization polls every 100ms, looking for a shell prompt before sending the e function definition. This avoids the race condition where the shell is not ready to receive input yet, a problem that plagued the vterm approach.

Once the prompt is detected, it sends a shell function definition over the wire. The injected e function resolves relative paths, checks the file exists, then emits an OSC 51 escape sequence that Eat intercepts. The payload is base64-encoded and tells Emacs to run find-file with the full remote path. Simplified, the shell function looks like:

e() {
  local f="$1"
  [[ "$f" != /* ]] && f="$PWD/$f"
  printf '\033]51;e;M;%s;%s\033\\' \
    "$(printf 'find-file' | base64)" \
    "$(printf '%s' "$f" | base64)"
}
copy

The actual Elisp function that generates and sends this (with proper escaping and error handling) is:

(defun sw-eat--tramp-init-string (prefix)
  "Return shell initialization string for TRAMP with PREFIX."
  (format "export TERM=xterm-256color
e() { [ -z \"$1\" ] && { echo 'usage: e FILE' >&2; return 1; }; \
local f=\"$1\"; [[ \"$f\" != /* ]] && f=\"$PWD/$f\"; \
[ ! -e \"$f\" ] && { echo \"e: $f: no such file\" >&2; return 1; }; \
printf '\\033]51;e;M;%%s;%%s\\033\\\\' \
\"$(printf 'find-file' | base64)\" \
\"$(printf '%s%%s' \"$f\" | base64)\"; }
clear\n" prefix))
copy

On the Emacs side, a message handler receives the find-file command and opens the file in another window:

(defun sw-eat-find-file-handler (path)
  "Open PATH in another window."
  (when (and path (not (string-empty-p path)))
    (find-file-other-window path)))

(add-to-list 'eat-message-handler-alist
             '("find-file" . sw-eat-find-file-handler))
copy

From a remote shell, e .bashrc opens /ssh:user@host:.bashrc in a local Emacs buffer.

IX. Workspaces with tab-bar

Doom uses persp-mode for workspaces. It is powerful but heavy, and its interaction with Evil and buffer management was a constant source of subtle bugs.

I replaced it with built-in tab-bar-mode, extended with buffer tracking per workspace. The tab bar itself is hidden (tab-bar-show nil), workspaces are managed entirely through keybindings and the echo area:

(defun sw-workspace--format-tab (index name is-current)
  "Format a single tab for echo area display."
  (let* ((num (1+ index))
         (label (if (and name (not (string-empty-p name)))
                    (format "[%d] %s" num name)
                  (number-to-string num)))
         (text (if is-current
                   (format "(%s) " label)
                 (format " %s  " label)))
         (face (if is-current
                   'sw-workspace-tab-selected-face
                 'sw-workspace-tab-face)))
    (propertize text 'face face)))
copy

Pressing SPC TAB TAB (same binding as Doom) shows the workspace bar in the echo area.

Workspace bar displayed in the echo area Every workspace operation (create, switch, close) also triggers a display update via advice on tab-bar functions.

a) Project workspaces

SPC p p (also same as Doom) switches to a project, creating a dedicated workspace for it. If a workspace for that project already exists, it switches to it instead:

(defun sw-workspace-switch-to-project ()
  "Switch project, opening it in a dedicated workspace."
  (interactive)
  (let* ((dir (project-prompt-project-dir))
         (name (file-name-nondirectory
                (directory-file-name dir)))
         (existing (member name (sw-workspace--get-names))))
    (if existing
        (tab-bar-switch-to-tab name)
      (let ((tab-bar-new-tab-choice #'sw-fallback-buffer))
        (tab-bar-new-tab)
        (tab-bar-rename-tab name)
        (delete-other-windows)
        ;; ...
        ))))
copy

b) Buffer tracking

Each workspace tracks which buffers belong to it. When a workspace is closed, its buffers are killed automatically:

(defvar sw-workspace-buffer-alist nil
  "Alist mapping workspace names to their buffer lists.")

(defun sw-workspace--track-buffer (&rest _)
  "Track current buffer in workspace buffer list."
  (when-let ((buf (current-buffer)))
    (unless (minibufferp buf)
      (sw-workspace--add-buffer buf))))

(add-hook 'window-buffer-change-functions
          #'sw-workspace--track-buffer)
copy

This replicates the most useful part of persp-mode, isolated buffer lists per workspace, without the complexity. The fallback buffer (*scratch*) is always preserved.

c) Quick switching

A macro generates numbered switching functions, so SPC TAB 1 through SPC TAB 9 jump directly to a workspace:

(defmacro sw-workspace--define-switchers ()
  "Define workspace switching functions 1-9."
  `(progn
     ,@(mapcar
        (lambda (n)
          `(defun ,(intern (format "sw-workspace-switch-to-%d" n)) ()
             ,(format "Switch to workspace %d." n)
             (interactive)
             (if (<= ,n (length (tab-bar-tabs)))
                 (tab-bar-select-tab ,n)
               (message "Workspace %d does not exist" ,n))))
        (number-sequence 1 9))))
copy

X. Keybindings and Evil

The Evil setup follows the same pattern as Doom: SPC as leader, which-key for discoverability (built into Emacs since version 30). I used Doom for so long that its keybinding conventions are burned into my muscle memory, and they were great to begin with, so I kept them.

a) Leader key

I dropped general.el since I was only using a fraction of what it offers - mostly general-create-definer and general-define-key, both easily replaced by a plain keymap and evil-define-key*.

A global minor mode owns the leader keymap. evil-define-key* binds SPC to it in normal, visual and motion states, with C-SPC as global fallback:

(defvar sw-leader-map (make-sparse-keymap)
  "Keymap for SPC leader bindings.")

(define-minor-mode sw-leader-mode
  "Global minor mode providing SPC as leader key."
  :global t :lighter nil
  :keymap (make-sparse-keymap))

(sw-leader-mode 1)

(evil-define-key* '(normal visual motion)
  sw-leader-mode-map
  (kbd "SPC") sw-leader-map)
(global-set-key (kbd "C-SPC") sw-leader-map)
copy

A small helper, sw-define-keys, wraps define-key and registers which-key labels in one pass. Each entry is a (KEY DEF LABEL) triple, where a nil DEF marks a prefix-only group:

(defun sw-define-keys (keymap bindings)
  "Define BINDINGS in KEYMAP with which-key labels.
BINDINGS is a list of (KEY DEF LABEL) entries.
DEF is a command or nil (prefix-only label)."
  (let (wk-args)
    (dolist (b bindings)
      (pcase-let ((`(,key ,def ,label) b))
        (when def
          (define-key keymap (kbd key) def))
        (when label
          (push key wk-args)
          (push label wk-args))))
    (when wk-args
      (apply #'which-key-add-keymap-based-replacements
             keymap (nreverse wk-args)))))
copy

This keeps all bindings declarative and compact. For example, the buffer group looks like:

(sw-define-keys sw-leader-map
  '(("b"   nil "Buffer")
    ("b b" sw-workspace-switch-buffer "Switch buffer")
    ("b d" kill-current-buffer "Kill buffer")
    ("b s" save-buffer "Save buffer")
    ...))
copy

The major prefix groups mirror Doom’s layout closely, SPC b for buffers, SPC f for files, SPC g for git, SPC s for search, SPC p for projects, SPC w for windows. SPC m dispatches to mode-specific bindings, just like Doom’s local leader. A menu-item filter looks up the current major mode in an alist and returns the matching keymap:

(defvar sw-local-leader-alist nil
  "Alist of (MODE . KEYMAP) for SPC m bindings.")

(define-key sw-leader-map "m"
  `(menu-item "Local mode" nil
    :filter ,(lambda (&optional _)
               (sw-local-leader-keymap))))
copy

Each mode registers its own keymap. For instance, Python gets test runners and import sorting under SPC m, while Emacs Lisp gets eval commands.

b) Window navigation

I use a non-standard keyboard layout, so window navigation needs both HJKL and YNAE bindings. A helper generates both from a single call:

(defun sw-define-window-nav-keys (prefix label)
  "Define window navigation bindings under PREFIX
with LABEL."
  (sw-define-keys sw-leader-map
    `((,prefix nil ,label)
      (,(concat prefix " <left>")
       evil-window-left "Window left")
      (,(concat prefix " <right>")
       evil-window-right "Window right")
      (,(concat prefix " <up>")
       evil-window-up "Window up")
      (,(concat prefix " <down>")
       evil-window-down "Window down")
      (,(concat prefix " y")
       evil-window-left "Window left")
      (,(concat prefix " n")
       evil-window-down "Window down")
      (,(concat prefix " a")
       evil-window-up "Window up")
      (,(concat prefix " e")
       evil-window-right "Window right")
      (,(concat prefix " v")
       sw-split-window-right "Split right")
      (,(concat prefix " s")
       sw-split-window-below "Split below"))))

(sw-define-window-nav-keys "w" "Window")
(sw-define-window-nav-keys "l" "Window")
copy

c) Symbol highlighting

Evil’s default * searches for the word at point but jumps to the next match. This custom version highlights the symbol at point without moving the cursor. Press n/N to jump between matches, Escape to clear:

(defun sw-highlight-symbol-at-point ()
  "Highlight symbol at point without moving."
  (interactive)
  (let* ((symbol (or (thing-at-point 'symbol t)
                     (user-error "No symbol at point")))
         (pattern (format "\\_<%s\\_>"
                          (regexp-quote symbol))))
    (setq evil-ex-search-pattern
          (evil-ex-make-search-pattern pattern)
          evil-ex-search-direction 'forward)
    (evil-push-search-history pattern t)
    (evil-ex-delete-hl 'evil-ex-search)
    (evil-ex-make-hl 'evil-ex-search)
    (evil-ex-hl-change 'evil-ex-search
                       evil-ex-search-pattern)
    (evil-ex-hl-update-highlights)))
copy

XI. Programming and formatting

a) Tree-sitter everywhere

Emacs 29+ has built-in tree-sitter support. A single remap table switches all major modes to their tree-sitter variants:

(setq major-mode-remap-alist
      '((c-mode . c-ts-mode)
        (python-mode . python-ts-mode)
        (go-mode . go-ts-mode)
        (rust-mode . rust-ts-mode)
        (js-mode . js-ts-mode)
        (json-mode . json-ts-mode)
        (sh-mode . bash-ts-mode)
        (yaml-mode . yaml-ts-mode)
        ;; ...
        ))
copy

b) Region formatting with dedenting

Apheleia handles whole-buffer formatting on save, but it does not support formatting a region. This custom region formatter fills that gap, and also handles indented code correctly. This comes up when formatting code inside Python docstrings or nested blocks, where the selection has leading indentation that confuses formatters:

(defun sw-format-region ()
  "Format the current region using language-specific tools."
  (interactive)
  (let* ((beg (region-beginning))
         (end (region-end))
         (formatter (alist-get major-mode sw-region-formatters))
         (input (buffer-substring-no-properties beg end))
         (indent (sw--string-min-indent input))
         (dedented (sw--string-reindent input indent 0)))
    ;; Format the dedented code, then reindent
    ;; ...
    (insert (sw--string-reindent output 0 indent))))
copy

The formatter strips leading indentation, pipes through the external tool (black, gofmt, shfmt, etc.), then reindents the result. Bound to ;f in visual state.

c) Eglot with booster

Eglot runs with minimal settings, async connection, no event buffer, and no inlay hints:

(use-package eglot
  :custom
  (eglot-autoshutdown t)
  (eglot-events-buffer-size 0)
  (eglot-sync-connect nil)
  (eglot-connect-timeout 10)
  (eglot-ignored-server-capabilities '(:inlayHintProvider)))
copy

Eglot-booster wraps LSP communication through emacs-lsp-booster, a Rust binary that handles IO buffering:

(use-package eglot-booster
  :ensure (:host github :repo "jdtsmith/eglot-booster")
  :after eglot
  :when (executable-find "emacs-lsp-booster")
  :init
  (setq eglot-booster-io-only (> emacs-major-version 29)))
copy

The io-only flag disables bytecode pre-compilation of JSON messages and relies on Emacs’ native JSON parser instead. Emacs 30 introduced a substantially faster JSON decoder, making bytecode translation unnecessary. The IO buffering benefits are still active either way.

XII. Backward kill word

A small but frequently useful enhancement. The default backward-kill-word is too aggressive, deleting entire compound identifiers in one shot. This version stops at underscores, hyphens, and other word boundaries:

(defun sw-backward-kill-word ()
  "Kill backward more gradually than `backward-kill-word'.
Stops at word boundaries including underscores and hyphens."
  (interactive)
  (let* ((start (point))
         (end (save-excursion
                (cond
                 ((bobp) (point))
                 ((looking-back "[ \t\n]" (1- (point)))
                  (skip-chars-backward " \t\n")
                  (when (= (- start (point)) 1)
                    (when (looking-back
                           "[^[:alnum:][:space:]]" (1- (point)))
                      (skip-chars-backward "^[:alnum:][:space:]"))
                    (when (looking-back
                           "[[:alnum:]]" (1- (point)))
                      (skip-chars-backward "[:alnum:]")))
                  (point))
                 ((looking-back "[_-]+" (line-beginning-position))
                  (skip-chars-backward "_-")
                  (when (looking-back
                         "[[:alnum:]]" (1- (point)))
                    (skip-chars-backward "[:alnum:]"))
                  (point))
                 (t
                  (skip-chars-backward "[:alnum:]")
                  (point))))))
    (kill-region end start)))
copy

Bound to C-Backspace globally. With the cursor at the end of my_long_variable_name, the default kills the whole thing. This version stops at each boundary:

  • my_variable_name -> my_variable_ -> my_variable -> my_ -> …
  • some-kebab-case -> some-kebab- -> some- -> …
  • foo(bar, baz) -> foo(bar, -> foo( -> …
  • hello world -> hello -> …

~~~

Wrapping up

This configuration keeps evolving with my workflow, and that is the point. Unlike Doom, where the framework owned most of the behavior and I was patching around the edges, everything here is mine. When something breaks, the fix lives in my code, not in a module interaction I never fully understood.

The core philosophy is simple: use built-in features where they are good enough (project.el, tab-bar, eglot, tree-sitter), add carefully chosen packages where they are not (Vertico, Corfu, Magit, Evil), and write custom glue only where no existing solution fits. Keep the UI minimal, the startup fast, and the code readable.

If you are considering a similar move, my advice is: do not try to replicate your current setup. Start with a blank file, add things as you actually miss them, and resist the urge to port everything at once. You will be surprised how little you actually need.

← back