Simple folding with Hideshow

Emacs has multiple built-in libraries for folding code, as is the case for most things Emacs. The default interface it exposes for folding functions is unwieldy and cumbersome, as is the case for most things Emacs.

There is some overlap between Hideshow-mode and Outline-minor-mode. The latter is mainly for folding and navigating nested Org-like headings, but can be extended with the Foldout library. (Also included in Emacs!) The former works well to hide nested blocks of code. Both libraries use regular expressions and only support a few languages out of the box, so tree-sitter based folding is going to be a welcome addition whenever it arrives. But that’s a story for another day. Today’s problem is the user interface, which is independent of the folding backend.

Here’s the keymap for folding-related functions in the two modes:

Key binding Hideshow mode Key binding Outline minor mode
C-c @ C-a hs-show-all C-c @ TAB outline-show-children
C-c @ C-c hs-toggle-hiding C-c @ C-k outline-show-branches
C-c @ C-d hs-hide-block C-c @ C-o outline-hide-other
C-c @ C-e hs-toggle-hiding C-c @ C-q outline-hide-sublevels
C-c @ C-h hs-hide-block
C-c @ C-l hs-hide-level
C-c @ C-s hs-show-block
C-c @ C-t hs-hide-all
C-c @ ESC Prefix Command
C-c @ C-M-h hs-hide-all
C-c @ C-M-s hs-show-all

This is irritating on two levels.

  • The key bindings are on a difficult to use keymap.
  • There’s no easy entry point and there are too many commands to do simple tasks.

The former is easily rectified by rebinding keys or defining a transient/hydra menu, but the latter takes more work. Designing a better interface to Outline mode’s folding functions was one of the original reasons for the creation of Org mode, which did a bang-up job: There’s no learning curve to cycling Org headings with TAB. It just works and there’s nothing to look up or remember!

As of Emacs 28, Outline-mode and its minor-mode variant have acquired outline-cycle, a convenient fold cycling function inspired by Org. If you’re on an older Emacs, there are packages for this: bicycle and outshine. For Hideshow mode there’s hideshow-org, but this bugged out for me because it makes assumptions about the behavior of my already overloaded TAB key.

So I took a crack at making a simple Org-like one-key interface to Hideshow.

(Direct link to demo if the embed fails to load.)

Here’s how it works (bind it to whatever, it’s C-TAB here):

  • C-TAB to cycle between showing unfolded, folded and showing children. (Same as Org)
  • C-TAB with a prefix argument to show arg levels. i.e. C-3 C-TAB will show unfolded up to the third level.
  • C-S-TAB to fold/unfold the whole buffer.

I find myself calling hs-cycle with a numeric level as the prefix arg all the time to get a top-down overview of code at different levels of detail. Here are three views of the same function, folded and unfolded to levels 2 and 4:

That’s it. This combines hs-show-all, hs-hide-all, hs-show-block, hs-hide-block, hs-toggle-hiding and hs-hide-level into two commands with a hopefully familiar usage pattern. It’s not much code either:

(defun hs-cycle (&optional level)
  (interactive "p")
  (let (message-log-max
        (inhibit-message t))
    (if (= level 1)
        (pcase last-command
          ('hs-cycle
           (hs-hide-level 1)
           (setq this-command 'hs-cycle-children))
          ('hs-cycle-children
           ;; TODO: Fix this case. `hs-show-block' needs to be
           ;; called twice to open all folds of the parent
           ;; block.
           (save-excursion (hs-show-block))
           (hs-show-block)
           (setq this-command 'hs-cycle-subtree))
          ('hs-cycle-subtree
           (hs-hide-block))
          (_
           (if (not (hs-already-hidden-p))
               (hs-hide-block)
             (hs-hide-level 1)
             (setq this-command 'hs-cycle-children))))
      (hs-hide-level level)
      (setq this-command 'hs-hide-level))))

(defun hs-global-cycle ()
    (interactive)
    (pcase last-command
      ('hs-global-cycle
       (save-excursion (hs-show-all))
       (setq this-command 'hs-global-show))
      (_ (hs-hide-all))))
Note to future self

This code looks like it has some redundant clauses you can refactor using hs-already-hidden-p, and like you don’t need to set last-command for all the clauses. Don’t try this, it breaks in subtle ways.