2024-04-15 Improving recenter-top-bottom and reposition-window

If one can be a fan of an Emacs command, then I am a huge fan of recenter-top-bottom (C-l) and reposition-window (C-M-l). I use them all the time to see the context of what I’m editing at the moment. However, they are not always that useful. They are rather crude – recenter-top-bottom only has three “settings”, as the name suggests, and reposition-window has only two (it either puts the first line of a function, or the first line of a comment preceding the function at the top). As I mentioned a few weeks ago, I sometimes work with rather long functions – and sometimes I am in the second of two shorter ones, but I want to see the first one, too. Also, I don’t only edit code – I edit prose, too, where paragraph play the role of functions, and Org files, where there are even other structural elements – headlines, tables and source blocks in addition to paragraphs, for example.

I decided to write a variation on the theme of reposition-window, which – instead of putting the first line of the function I’m in at the top, it tries to put the first line of a “section” I’m in at the top. What a “section” is can be a bit vague. For example, in the simplest case, a “section” is just a fragment of the buffer separated from the rest by one or more blank lines. This may mean a paragraph (in prose), or a logical part of a function in code (assuming that the programmer puts such blank lines in suitable places, which is a very good practice anyway – putting them between functions is a minimum, and putting a few of them in longer and more complicated functions is what we do at my company anyway). For a more complex scenario, consider an Org mode headline or block which also start a new section.

Of course, this is still pretty imprecise, and there are quite a few ways it could work, especially in corner cases. One possible implementation I came up with is the following code.

(defconst reposition-window-section-re "\\`\\|\n\\s-*\n\\|^\\*+[ ]\\|#\\+begin"
  "Regular expression matching a \"section\" beginning.
For `reposition-window-section' to work properly, this must match
the beginning of the buffer, too.")

(defun reposition-window-section ()
  "Make the current section visible.
Here, \"section\" means a fragment starting with something that
matches `reposition-window-section-re'.  If the current section
is already visible, scroll up so that one more section becomes
visible, and if that is not possible, scroll down so that only
the current section is.  If the current section is larger than
the screen, fall back to `recenter-top-bottom'."
  (interactive)
  (save-excursion
    (let ((orig (point))
          (at-top (pos-visible-in-window-p (point-min))))
      (unless at-top
        (goto-char (window-start))
        (while (progn (re-search-backward reposition-window-section-re nil t)
                      (or (invisible-p (point))
                          (pos-visible-in-window-p (match-end 0)))))
        (goto-char (match-end 0))
        (forward-line 0)
        (set-window-start (selected-window) (point)))
      ;; if going to one section up moves point out of the window, go to the section right before point
      (when (or (not (pos-visible-in-window-p orig))
                ;; do the same if we started at the top of the buffer
                at-top)
        (goto-char orig)
        (re-search-backward reposition-window-section-re nil t)
        (goto-char (match-end 0))
        (forward-line 0)
        (set-window-start (selected-window) (point))
        ;; if that also moves point out of the window, just (recenter-top-bottom)
        (unless (pos-visible-in-window-p orig)
          (goto-char orig)
          (recenter-top-bottom))))))

(global-set-key (kbd "C-S-l") #'reposition-window-section)

It was my third attempt (not counting a lot of minor changes). The first one was needlessly complicated, and depended on (eq last-command #'reposition-window-section) so that pressing C-S-l twice in a row worked differently from pressing it once, doing anything else (e.g. small-scale point motion like C-f) and pressing it again. I generally consider commands which rely on external state like that inferior design, and while sometimes it is exactly what is called for, I decided that I’d better avoid it. (Interestingly, recenter-top-bottom, which is sort of a grandfather of my command, does exactly that. That leads to a subtle issue when the point is already in the middle line. In that case, it does nothing, which is potentially confusing for the user.) The second one was simply buggy (and I really do hope this one is not).

I am still not 100% satisfied with this design, which seems not very elegant with all the unless and when clauses. I like to comfort myself that elegance in Elisp commands is sometimes very difficult to achieve. Text editing is a surprisingly complicated subject (as evidenced for example by an excellent series of articles about tree-sitter and Combobulate by Mickey Petersen), and making your editor behave in an intuitive and useful way means dealing with quite a few non-obvious edge cases. This is exactly the situation here. The main idea is simple – to have a regex matching a “section beginning”, and to move the text in the window so that more and more “sections” are fully visible. Here are the possible special cases I could imagine.

  1. The section beginning regex could match a fragment of one line or could span several lines.
  2. Showing “one more section” could move the point out of the window.
  3. The section the point is in could be larger than the screen.
  4. We could have started at the top of the buffer (that is, (point-min) could be already visible).
  5. There could be one no section beginnings before the point.
  6. The section above the screen could be folded.

The first problem deserves an explanation. As I mentioned, one possible notion of a “section beginning” is one or more blank lines. In that case, that beginning itself should not be visible – there is no point in showing the user empty lines, especially in a command whose main purpose is to make the best use of limited screen real estate. However, an equally valid notion of a “section beginning” (for Org mode files at least) is a line beginning with one or more stars or a #+begin_whatever block. In that case we definitely want it to be visible. My solution, covering both cases, is a bit hackish, but I like to think that it’s clever: the first line visible will be the line where the end of the “section beginning” falls. This covers both cases described above.

Also, this is the reason why it is not enough to go to (window-start) and search for reposition-window-section-re backwards. When looking for the “one or more blank lines” type of section beginning, and assuming that these blank lines fall exactly above what is visible on the screen, when we go to (match-end 0) we end up exactly where we started. This means that in this situation we need to search backwards twice. The most elegant way of expressing this in code is a repeat-until-type loop – which in Elisp is implemented as a while loop with the whole body packed into the while condition – and repeating the search until (match-end 0) is no longer visible on the screen.

The second problem is pretty easy to solve. After we move (window-start) to the beginning of the section we’ve found, we need to check if the starting point is still visible. If not, we need to go back to where we started and look for the nearest section beginning above. This way, our command will “cycle” – show more and more sections until it’s impossible without losing the point from view, and then it will show just the section the point is in and as much as possible below.

However, it is also possible that we couldn’t do even that (item 3). In that case, we just fall back to (recenter-top-bottom). It’s not ideal, but at least we do something, so that the user clearly sees that something is happening. (I wanted to avoid the situation where pressing C-S-l does nothing at all. It is still possible – calling recenter-top-bottom when exactly in the middle of the screen indeed appears to do nothing, as I mentioned before – but working around an extremely rare special case where just pressing C-S-l again solves the issue anyway didn’t seem worth it.)

If all that were not enough, it is also possible that we start at the top of the buffer (that is, with (point-min) already visible). If that is the case, we cannot show more above the point, so we should do the same thing as in item 2 – move the section the point is in to the top.

Similarly, it may happen that there is no section beginning before point - in other words, we are in the first section in the buffer and our regex does not really match a section beginning per se, but a section separator. I’m on the fence with respect to this one – there is a very simple way to avoid this altogether, and that is to include \` as one of the variants in reposition-window-section-re. This regex matches the beginning of the buffer, so including it ensures that it’s always treated as a beginning of a section. On the other hand, if a user forgets to include it when customizing reposition-window-section-re, reposition-window-section will work incorrectly in this situation. After a short consideration I decided that I’ll go for the simpler code and mention in the docstring that this must be included manually. The assumption here is that most users (if my code ever has any user besides me, that is;-)) won’t even customize that option, and if they do, they have the docstring, and if they don’t read it, reposition-window-section will still work in most cirumstances. (Of course, I could add \` to the regex in my function each time it is run, or – if I decided to get really fancy – I could also add some code ensuring \`\| is added to the variable value in the {{{:set}}} parameter of {{{defcustom}}}.)

Last but not least, the section(s) above the portion visible on the screen could be invisible (this happens all the time with Org mode structure folding). That is pretty easy to solve – we can just keep looking for sections until the one we’ve found is visible.

Ok, that was quite a trip. The end result is still not ideal. For instance, I can imagine that a prefix argument could cause this command to scroll down instead of up. This way more and more context below the point would become visible. It is not obvious to me whether it would be better to keep showing whole sections at the top or at the bottom of the screen then – I guess bottom would make more sense. To achieve this, I would revisit the idea of checking last-command – it would be very inconvenient to have to have to type C-u C-S-l C-u C-S-l instead of just C-u C-S-l C-S-l, for example. Another way to make using a prefix argument with a command which tends to be repeated many times in a row would be to bind it to some key sequence, say C-x C-S-l, and use the mechanism of transient keymaps. I have to admit that I never used that and I’m not sure if this would even work, that is, if subsequent invocations would receive the same prefix argument as the first one – I’m afraid not, but it’s something worth investigating. I’ll look into it some day, I think. Still, the command is pretty useful even without that feature, and I have to say that it was useful even before I fleshed it out, while it was still buggy!

The last thing I’d like to say in this already rather long post is that if you’d like to learn how to code various convenience commands like this, I always recommend two sources – Introduction to programming in Emacs Lisp by the late Robert J. Chassell first, and my book, Hacking your way around in Emacs, if you want to dive a bit deeper. In fact, I am very much tempted to add this exact command as a topic of another chapter in that book – it is neither too simplistic nor too complex, and it is potentially useful to everyone (it does not depend on whether you use Emacs for coding or for prose). But that is more like a long-term plan.

CategoryEnglish, CategoryBlog, CategoryEmacs