Magical Tabs in Emacs



March 2026

Tab is the magical key that does everything: indentation, code completion, code folding, minibuffer completion, and mode-specific things like refreshing buffers and jumping between spreadsheet cells.

By default, it’s not as unified as it could be:

  1. The visibility cycling available in markdown-mode and org-mode is insanely useful, but it only works in those modes
  2. Modes tend to completely overwrite the key, killing whatever autocompletion you set up.
  3. But if you overwrite tab globally (e.g. with your own minor mode), then you lose mode-specific functionality.
  4. Autocompletion is bound to M-tab (also called <backtab>), and it sucks

You could separate all of the commands into their own keybindings and just forget about tab. I see a lot of people doing that. But something about that bothers me - why can’t Tab just do what I want?

Emacs has a built-in mechanism to configure how Tab behaves, which I will describe below. This is a common setting, but it is incomplete if you want code folding, or if you want autocompletion to work while writing prose.

Core rules

It’s always best to work the settings Emacs provides, then add features when you understand what’s missing. Start with these rules:

  1. don’t bind tab globally, leave it alone and let other modes overwrite it if they want to

  2. configure indent-for-tab-command so that it indents or autocompletes intelligently using the following code:

(setq
 tab-always-indent 'complete
 tab-first-completion 'word-or-paren-or-punct) 

This gets us about a third of the way there. You should now have a tab key that is overloaded with indentation and autocompletion in all prog-mode buffers.

There is also a third rule:

  1. when you do rebind tab, follow the example of org-cycle and markdown-cycle by passing through to indent-for-tab-command, the default tab behavior

We will use this in a minute, with my wrycode/prog-cycle command, but first we need to fix something.

Fixing tab indentation in text-mode

The above fix doesn’t work in markdown-mode or text-mode. The technical reason is that indent-line-function is set to indent-relative, which never returns :noindent so it never triggers autocompletion. 1

You could definitely view this as a bug , because it breaks the behavior of indent-for-tab-command in those modes, but in all fairness to the mode authors, there’s no consensus about how indentation should work in a plain text buffer. It’s pretty ambiguous.

At any rate, if we want tab completion, we’ll need to write our own indent-line-function with an unambiguous indentation scheme. This part is personal, so feel free to take my code and adapt it to your needs.

(defun get-indent-point-at-line ()
  "Return the column of the first non-whitespace character on current line."
  (save-excursion
    (beginning-of-line)
    (skip-chars-forward " \t")
    (current-column)))

(defun get-previous-line-indent-point ()
  "Return the column of the first non-whitespace character on the previous
line, or 0 if there is no previous line."
  (save-excursion
    (if (= (forward-line -1) 0)  ; forward-line returns 0 on success
        (get-indent-point-at-line)
      0)))

(defun wrycode/indent-prose ()
  (let ((bol (line-beginning-position))
		(eol (line-end-position)))
	 (if (string-match-p "^[ \t]*$" (buffer-substring bol eol))
		;; blank line
		(let ((prev-indent (get-previous-line-indent-point))
			 (current-col (current-column)))
		  (cond
		   ;; Case 1: Not at previous indent point - move there
		   ((not (= current-col prev-indent))
              (delete-region bol eol)
              (indent-to prev-indent))
		   ;; Case 2: Already at previous indent point - add one tab
		   (t
              (insert "\t"))))
	   
	   ;; line with text already
	   (let* ((current-indent (get-indent-point-at-line ))
			(prev-indent (get-previous-line-indent-point))
			(valid-indent-1 prev-indent)
			(valid-indent-2 (+ prev-indent tab-width))
			(point-col (current-column))
			(point-before-indent (< point-col current-indent))
			(moved-something nil))
		
		;; If point is before indent point, move it there
		(when point-before-indent
		  (move-to-column current-indent)
		  (setq moved-something t))
		
		;; Check if current indent is already valid
		(if (and (not moved-something)
				 (or (= current-indent valid-indent-1)
					 (= current-indent valid-indent-2)))
			'noreturn
		  
		  ;; Sync indent point with previous line if needed
 		  (unless (= current-indent prev-indent)
		    (save-excursion
		      (beginning-of-line)
		      (delete-horizontal-space)
		      (indent-to prev-indent))
		    (setq moved-something t))
		  
		  ;; After syncing, if point ended up before new indent, move it
		  (when (and moved-something (< (current-column) prev-indent))
		    (move-to-column prev-indent))
		  
		  ;; Return 'noreturn if nothing changed
		  (if moved-something nil 'noreturn))))))

(add-hook 'text-mode-hook (lambda () (setq-local indent-line-function 'wrycode/indent-prose)) )
(add-hook 'markdown-mode-hook (lambda () (setq-local indent-line-function 'wrycode/indent-prose)) )
(add-hook 'org-mode-hook (lambda () (setq-local indent-line-function 'org-indent-line)) )

Universal visibility cycling

As I mentioned before, markdown-mode and org-mode have extremely helpful commands for cycling the visibility of headings.

Press Tab on any heading and it will cycle the visibility of that heading and headings below it. Press S-TAB (Shift + Tab) and it will do the same for the whole buffer.

Since I also use tab for autocompletion, I like to use the following setting:

;; makes org-cycle only work at the beginning of the line
(setq org-cycle-emulate-tab 'exc-hl-bol)

But it always bothered me that I couldn’t have the equivalent visibility cycling while programming.

One option is to use outline-minor-mode and enable outline-minor-mode-cycle in prog-mode buffers, and that will sort of get you there. You just have to add “headings” in the code comments that match outline-regexp.

However, I really wanted to fold the code directly using the structure of the code. This is how most IDEs do it. I also don’t like the idea of using custom markup that is only supported by my editor.

The builtin solution for this kind of code folding is hs-minor-mode:

(add-hook 'prog-mode-hook 'hs-minor-mode)

But the keybindings are atrocious:

alt text

Karthinks wrote some cycling commands to emulate org-cycle. It’s amazing how every time I identify a pain point, that guy has read my mind and built something useful.

However that still isn’t quite what I wanted – his hs-cycle needs to be bound to another key, and I wanted Tab to function exactly like in org-mode or markdown-mode, so I wrote my own version called prog-cycle. There are some subtleties here that scratched an itch for me. For instance:

Here’s the code:

(defun wrycode/prog-cycle (&optional level)
  "Emulates org-mode's tab cycling using builtin hideshow. Tries to cycle
visibility when it makes sense, and otherwise fallback to the default
indent-for-tab-command.

Basically, if your cursor is at the beginning of a line where there is
an opening block, tab will cycle visibility just like org mode
headings. It will also reset the global cycling state for
`wrycode/prog-cycle-global' for the same intuitive feel as the org-mode
cycling commands."
  (interactive "p")
  (let ((current-line (line-number-at-pos))
        (cycled nil))
    (if (bolp) ; Only attempt cycling if at the beginning of the line
        (save-excursion
        ;; Move to the end of line to get "inside" any block that might exist on the line
	  (end-of-line)
        ;; Only proceed if hideshow finds a block that begins on this
        ;; line
          (when (and (hs-find-block-beginning)
                     (= current-line (line-number-at-pos (point))))
             (setq cycled t) ; Mark that we're cycling
	     (setq wrycode/global-cycle-state 2) ; resets global cycling state
          (pcase last-command
            ('prog-cycle-children
             (save-excursion (hs-show-block))
             (setq this-command '_))
            (_
             ;; Initial press.  If block is visible, hide it. If we
             ;; already hid it, show level one
             (if (not (hs-already-hidden-p))
                 (hs-hide-block)
               (hs-hide-level 1)
               (setq this-command 'prog-cycle-children)))))))
    (unless cycled ; fall back to default tab behavior
	(call-interactively 'indent-for-tab-command))))
	
(defvar wrycode/global-cycle-state 0)

(defun wrycode/prog-cycle-global ()
  "Org-shifttab, but in prog-mode"  
  (interactive)
  (setq wrycode/global-cycle-state (mod (1+ wrycode/global-cycle-state) 3))
  (pcase wrycode/global-cycle-state
    (0 (hs-hide-all))
    (1 (hs-hide-level 3))    
    (2 (hs-show-all))))

To enable this code cycling, just bind it locally in prog-mode:

(add-hook 'prog-mode-hook
	  (lambda ()
	    (local-set-key (kbd "TAB") 'wrycode/prog-cycle)
   		(local-set-key (kbd "<backtab>") 'wrycode/prog-cycle-global)))

Autocompletion

We now have a tab that does everything, but the default completion still kind of sucks. You have to use the mouse or arrow keys to actually use the completions, and the completion options aren’t very good.

I was going to do my full autocompletion setup here, but I decided to defer until another day. At any rate, you can get started pretty easily with company , corfu + cape , or for the truly lazy, just install ivy and enable ivy-mode.


  1. If you want to learn more about this, you can read the documentation in Emacs:(info "(emacs) Indentation") and inspect the varibales indent-line-function and indent-relative ↩︎