Andrey Listopadov

Limiting Horizontal Scroll In Emacs

I use Emacs mainly for programming, but I also write a lot of non-code in it, either for this blog, documentation for my projects, or just when I take notes. And I do it with a git-friendly style of formatting with a single sentence per line. And because of that, I enable line truncation, to avoid wrapping text, because when the text is written this way, the wrapped version looks like a mess, in my opinion. This also helps with code, that has long lines - the formatting is preserved and unexpected multi-line expressions don’t mess me up.

But because lines get truncated when exceeding window width, it means that I have to use horizontal scrolling. I rarely actually use scrolling, because this is a very good indication of a very long sentence, that should be rephrased into smaller ones, but still. However, when I do have to scroll, there’s a problem - Emacs doesn’t limit horizontal scrolling properly.

If you open some other editor, like Gedit, and disable line wrapping, you’ll see that the most amount of horizontal scrolling you can get is basically such that the last character of the longest line is at the right border of the window. In Emacs, however, it’s not limited like that, and you can scroll past the last character of the longest line, and basically overshoot. This can be handy when you want to have some empty space to the right, for comfortable writing, but I don’t need that. Instead, I would like to get the behavior of editors like Gedit.

Turns out, if you enable horizontal-scroll-bar-mode, Emacs will show the scroll-bar at the bottom, which will not allow you to over-scroll text, like in Gedit. But if you use a touchpad, or manually call scroll-left it will continue scrolling. So let’s fix this.

The first thing we’ll need is a custom predicate, that will check if any line in the buffer exceeds buffer width. There are some corner cases to be aware of, like if the buffer’s font size is changed via text-scale-adjust, or if the line numbers are displayed, so this predicate should account for that too. I wrote such predicate like this:

(defun truncated-lines-p ()
  "Non-nil if any line is longer than `window-width' + `window-hscroll'.

Returns t if any line exceeds the right border of the window.
Used for stopping scroll from going beyond the longest line.
Based on `so-long-detected-long-line-p'."
  (save-excursion
    (goto-char (point-min))
    (let* ((window-width
            ;; This computes a more accurate width rather than `window-width', and
            ;; respects `text-scale-mode' font width.
            (/ (window-body-width nil t) (window-font-width)))
           (hscroll-offset
            ;; `window-hscroll' returns columns that are not affected by
            ;; `text-scale-mode'.  Because of that, we have to recompute the correct
            ;; `window-hscroll' by multiplying it with a non-scaled value and
            ;; dividing it with a scaled width value, rounding it to the upper
            ;; boundary.  Since there's no way to get unscaled value, we have to get
            ;; a width of a face that is not scaled by `text-scale-mode', such as
            ;; `window-divider' face.
            (ceiling (/ (* (window-hscroll) (window-font-width nil 'window-divider))
                        (float (window-font-width)))))
           (line-number-width
            ;; Compensate for line number width.  Add support for
            ;; other modes if you use any, like `linum-mode'.
            (if (bound-and-true-p display-line-numbers-mode)
                (- display-line-numbers-width)
              0))
           (threshold (+ window-width hscroll-offset line-number-width
                         -2))) ; -2 to compensate rounding during calculation
      (catch 'excessive
        (while (not (eobp))
          (let ((start (point)))
            (save-restriction
              (narrow-to-region start (min (+ start 1 threshold)
                                           (point-max)))
              (forward-line 1))
            (unless (or (bolp)
                        (and (eobp) (<= (- (point) start)
                                        threshold)))
              (throw 'excessive t))))))))

To illustrate what it does, here’s a screenshot of the Emacs window with this blog post, as I edit it:

I’ve made the frame smaller and enabled the horizontal-scroll-bar-mode to better show what I mean. In the echo area t is displayed because I’ve called truncated-lines-p, and it shows that some line exceeds the width of the window. You can see that lines go beyond the right border (though there’s no arrow in the fringe, as I disabled these), and if I scroll with the scroll-bar, Emacs stops scrolling at about this position:

Now nil is displayed in the echo area because I’ve called truncated-lines-p again, which means that the predicate works, since there’s no need to scroll anymore, all lines are not truncated by the right border. However, I can continue scrolling if I use the touchpad:

Note, that the handle in the scroll bar changed its width. Emacs calculates the width of the handle based on the longest line currently visible in the window, meaning that it will change depending on the position in the buffer. And perhaps scroll-bar width limited for what you’re seeing is a performance optimization. My function does it for the whole buffer, and there is a lot of code for calculating text width with respect to the horizontal scroll. Thus, the calculation process may not seem very optimal, but benchmarking this function seems to have decent performance on my 1603 lines init.el file:

;; some lines exceed the width
(benchmark 1000 '(truncated-lines-p)) ; Elapsed time: 0.597171s
;; no lines exceed the width
(benchmark 1000 '(truncated-lines-p)) ; Elapsed time: 1.247346s

In practice, I never feel any slowdown from it. The only thing left is to make sroll-left to actually use it:

(define-advice scroll-left (:before-while (&rest _) prevent-overscroll)
  (and truncate-lines
       (not (memq major-mode '(vterm-mode term-mode)))
       (truncated-lines-p)))

I use a before-while advice, which acts like a predicate on its own and doesn’t run function if the result is nil. As can be seen, it does an additional check for truncate-lines to be on, since if lines are not truncated there’s no reason to ever scroll left. I also would like to prevent this from working in certain major modes, like terminals, since these automatically wrap lines themselves.

Hope this is useful for someone, and if anybody knows a more robust way to calculate text width with the respect to the zoom level feel free to suggest improvements.