Subject: 30.0.50; Horizontal overscroll

From: Andrey Listopadov <[email-masked]>
Date: Sun, 28 Jan 2024 23:41:57 +0300

Hello.

Perhaps it is not a bug, but a feature request, although I have seen
examples when the default behavior can be considered a bug by some
people unfamiliar with Emacs.  I'm hardly unfamiliar with Emacs, but I
also consider the default behavior somewhat weird and less useful than
what I'm going to describe here.

Imagine, this is a GUI Emacs with one window, and
`horizontal-scroll-bar-mode' is enabled:

|_____________Emacs____________|
|;; This buffer is for text tha|
|;; To create a file, visit it |
|                              |
|                              |
|                              |
|______________________________|
|============------------------| <- horizontal scrollbar
|______________________________| <- mode-line & echo area below

I'm a laptop user, and I mostly use the touchpad to navigate through the
text in the window.  Vertical scrolling is handled by the
`pixel-scroll-precision-mode' and I love how smooth the text is being
scrolled with it.

Horizontal scrolling is enabled by setting the `mouse-wheel-tilt-scroll'
variable, so my touchpad allows me to scroll the text in all directions.

As you can see in the drawing above, the `*scratch*' buffer is
displayed, and the text goes beyond the window borders.  This is not the
default behavior, but I enabled line truncation to illustrate.  The
scrollbar is denoted with `=' for the draggable portion and `-' for the
rest of the scrollbar.

Now, in `emacs -Q', after enabling line truncation and horizontal
scrolling, let's scroll the text to the right side of the text:

|_____________Emacs____________|
|, and for Lisp evaluation.    |
| and enter text in its buffer.|
|                              |
|                              |
|                              |
|______________________________|
|------------------============|
|______________________________|

So far so good.

Now, since I'm using the touchpad, I'll also set
`mouse-wheel-tilt-scroll' for this session.  With that, we can scroll
the text back with the touchpad:

|_____________Emacs____________|
|r text that is not saved, and |
| visit it with ‘C-x C-f’ and e|
|                              |
|                              |
|                              |
|______________________________|
|---------============---------|
|______________________________|

However, when scrolling to the right with the touchpad (to avoid
confusion view goes to the right and the text goes to the left), we can
do this:

|_____________Emacs____________|
|ation.                        |
|ts buffer.                    |
|                              |
|                              |
|                              |
|______________________________|
|-------------------------=====|
|______________________________|

The scrollbar itself doesn't allow us to go beyond the longest text
visible text.  Scrolling with the touchpad, however, allows for this.
We can even do this:

|_____________Emacs____________|
|                              |
|                              |
|                              |
|                              |
|                              |
|______________________________|
|----------------------------==|
|______________________________|

Or even this:

|_____________Emacs____________|
|                              |
|                              |
|                              |
|                              |
|                              |
|______________________________|
|-----------------------------=|
|______________________________|

The text is so far left, that the draggable portion of the scrollbar no
longer shrinks.

Two parts are counter-intuitive to most people I know.

The first one is that the scrollbar only allows for scrolling as far as
the longest VISIBLE line. In most other software that provide text
editing capabilities, the scrollable area is a rectangle shape that
wraps the longest line in the entire buffer and the total amount of
lines.  Something like this:

        window
        width
     <----------->
      __________________
   ^ |some line  |     | ^
   : |some line  |     | :
w  h |           |     | s
i  e |longer line|     | c
n  i |           |     | r  h
d  g |           |     | o  e
o  h |           |     | l  i
w  t |           |     | l  g
   : |           |     |    h
   v |-----------|     | b  t
     |the longest| line| o
     |           |     | x
     |           |     | :
     |the last li|ne   | v
     <---------------->
        scroll box
           width

I hope you can decypher the dimensions of the window and the scroll box.

Emacs, however, uses the vertical size of the scroll box conventionally
but limits the horizontal size to the longest visible line.

The second unconventional part is that we can increase the width of the
scroll box by simply scrolling with the touchpad.  Usually, that's not
the case. I know that in Emacs we can also scroll beyond the last line
in the document for editing convenience, and the behavior of the
horizontal scroll can be described similarly, but the behavior of the
scrollbars doesn't match.

For instance, we can scroll past the last line of text with the vertical
scroll bar, but we can't do the same with the horizontal scrollbar.  We
can't scroll horizontally long past the last character in the line, but
we can't scroll vertically long past the last line in the buffer.

I've been using the following piece of code for some years to workaround
this problem:

(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'."
  (let ((buffer (current-buffer))
        (tabwidth tab-width))
    (or (> (buffer-size buffer) 1000000) ; avoid searching in huge buffers
        (with-temp-buffer
          (insert-buffer-substring buffer)
          (setq-local tab-width tabwidth)
          (untabify (point-min) (point-max))
          (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 line numbers width
                  (if (bound-and-true-p display-line-numbers-mode)
                      (- display-line-numbers-width)
                    0))
                 (threshold (+ window-width hscroll-offset line-number-width
                               -2))) ; compensate imprecise calculations
            (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))))))))))

This function checks if any lines in the buffer exceed the width of the
window.  I tried my best to take into account things like text scaling,
width of the line numbers, and horizontal scroll offset.  It works
reliably enough, although not as precise as the horizontal scrollbar.

I use it with the following advice:

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

This function is not cheap, as it walks through every line in the
buffer, calculating its width, so I restrict this function to buffers
smaller than 1000000 characters. I also have to `untabify' the whole
buffer in a temporary buffer because tab characters don't play too well
with width calculation.

Sorry for such a long explanation of the issue, but I can't describe it
in shorter terms, because I already tried a few years back on Reddit,
and nobody understood the problem I'm describing.

I would like to ask for a feature to limit the horizontal scroll by the
longest line, much like the horizontal scrollbar works by default. It
may not be the longest line in the buffer, as calculating this for huge
buffers is probably too impactful unless we can cache the longest line
length until the buffer is changed.  Or maybe Emacs already knows the
buffer's "dimensions", I don't know.

Hope my explanation was clear enough.

In GNU Emacs 30.0.50 (build 1, x86_64-pc-linux-gnu, GTK+ Version
 3.24.38, cairo version 1.17.8) of 2024-01-28 built on toolbox
Repository revision: adf32eb69ea34b9c057c9a4321e5f05b00a7c940
Repository branch: master
System Description: Fedora Linux 38 (Container Image)

Configured using:
 'configure --without-compress-install --with-native-compilation=yes
 --with-pgtk --with-mailutils --with-xwidgets
 --prefix=/var/home/alist/.local'

Configured features:
ACL CAIRO DBUS FREETYPE GIF GLIB GMP GNUTLS GPM GSETTINGS HARFBUZZ JPEG
JSON LCMS2 LIBOTF LIBSELINUX LIBXML2 MODULES NATIVE_COMP NOTIFY INOTIFY
PDUMPER PGTK PNG RSVG SECCOMP SOUND SQLITE3 THREADS TIFF
TOOLKIT_SCROLL_BARS TREE_SITTER XIM XWIDGETS GTK3 ZLIB

Important settings:
  value of $LANG: en_US.UTF-8
  locale-coding-system: utf-8-unix

Major mode: ELisp/l

Minor modes in effect:
  global-git-commit-mode: t
  magit-auto-revert-mode: t
  outline-minor-mode: t
  electric-pair-mode: t
  isayt-mode: t
  savehist-mode: t
  delete-selection-mode: t
  pixel-scroll-precision-mode: t
  global-auto-revert-mode: t
  repeat-mode: t
  vertico-mode: t
  marginalia-mode: t
  corfu-popupinfo-mode: t
  global-corfu-mode: t
  corfu-mode: t
  global-region-bindings-mode: t
  recentf-mode: t
  server-mode: t
  common-lisp-modes-mode: t
  override-global-mode: t
  puni-mode: t
  tooltip-mode: t
  global-eldoc-mode: t
  eldoc-mode: t
  show-paren-mode: t
  electric-indent-mode: t
  mouse-wheel-mode: t
  menu-bar-mode: t
  file-name-shadow-mode: t
  context-menu-mode: t
  global-font-lock-mode: t
  font-lock-mode: t
  blink-cursor-mode: t
  minibuffer-regexp-mode: t
  column-number-mode: t
  line-number-mode: t
  transient-mark-mode: t
  auto-composition-mode: t
  auto-encryption-mode: t
  auto-compression-mode: t
  hs-minor-mode: t

Load-path shadows:
/var/home/alist/.config/emacs/elpa/transient-20240121.2000/transient hides /var/home/alist/.local/share/emacs/30.0.50/lisp/transient
/var/home/alist/.config/emacs/elpa/modus-themes-20240104.1122/theme-loaddefs hides /var/home/alist/.local/share/emacs/30.0.50/lisp/theme-loaddefs

Features:
(shadow mail-extr sort emacsbug tabify help-fns radix-tree misearch
multi-isearch mu4e mu4e-org mu4e-main mu4e-view mu4e-headers
mu4e-compose mu4e-draft mu4e-actions smtpmail mu4e-search mu4e-lists
mu4e-bookmarks mu4e-mark mu4e-message flow-fill hl-line mu4e-contacts
mu4e-update mu4e-folders mu4e-server mu4e-context mu4e-vars mu4e-helpers
mu4e-config magit-bookmark bookmark ido puni pulse color vc-hg vc-bzr
vc-src vc-sccs vc-svn vc-cvs vc-rcs log-view vc bug-reference flyspell
ispell magit-extras face-remap vc-git vc-dispatcher vertico-directory
mule-util ol-eww eww xdg url-queue mm-url ol-rmail ol-mhe ol-irc ol-info
ol-gnus nnselect gnus-art mm-uu mml2015 mm-view mml-smime smime gnutls
dig gnus-sum shr pixel-fill kinsoku url-file svg dom gnus-group
gnus-undo gnus-start gnus-dbus gnus-cloud nnimap nnmail mail-source utf7
nnoo parse-time gnus-spec gnus-int gnus-range gnus-win gnus nnheader
range ol-docview doc-view jka-compr image-mode exif ol-bibtex bibtex
iso8601 ol-bbdb ol-w3m ol-doi org-link-doi org-tempo tempo cus-start
blog org-capture org-refile ob-fennel fennel-proto-repl fennel-mode
thingatpt inf-lisp xref magit-submodule magit-blame magit-stash
magit-reflog magit-bisect magit-push magit-pull magit-fetch magit-clone
magit-remote magit-commit magit-sequence magit-notes magit-worktree
magit-tag magit-merge magit-branch magit-reset magit-files magit-refs
magit-status magit magit-repos magit-apply magit-wip magit-log
which-func imenu magit-diff smerge-mode diff diff-mode git-commit
log-edit message sendmail yank-media puny rfc822 mml mml-sec epa epg
rfc6068 epg-config gnus-util mm-decode mm-bodies mm-encode mail-parse
rfc2231 rfc2047 rfc2045 mm-util ietf-drums mail-prsvr mailabbrev
mail-utils gmm-utils mailheader pcvs-util add-log magit-core
magit-autorevert magit-margin magit-transient magit-process with-editor
magit-mode transient magit-git magit-base magit-section cursor-sensor
crm project ob-lua ob-shell shell org ob ob-tangle ob-ref ob-lob
ob-table ob-exp org-macro org-src ob-comint org-pcomplete pcomplete
org-list org-footnote org-faces org-entities time-date ob-emacs-lisp
ob-core ob-eval org-cycle org-table ol org-fold org-fold-core org-keys
oc org-loaddefs find-func cal-menu calendar cal-loaddefs org-compat
org-version org-macs format-spec noutline outline elec-pair isayt
disp-table hideshow savehist delsel pixel-scroll cua-base autorevert
filenotify repeat vertico marginalia corfu-popupinfo cape corfu compat
region-bindings recentf tree-widget init gnome-proxy gsettings s
gvariant parsec dash clojure-compilation-mode derived compile
text-property-search comint ansi-osc ansi-color ring server treesit
dired dired-loaddefs use-package-delight formfeed
modus-vivendi-tritanopia-theme modus-operandi-tritanopia-theme
modus-vivendi-deuteranopia-theme modus-operandi-deuteranopia-theme
modus-vivendi-tinted-theme modus-operandi-tinted-theme
modus-vivendi-theme modus-operandi-theme modus-themes dbus xml
common-lisp-modes novice cus-edit pp cus-load wid-edit font mode-line
messages defaults edmacro kmacro functions use-package-bind-key bind-key
local-config delight comp comp-cstr warnings icons comp-run comp-common
rx use-package-ensure cl-extra help-mode use-package-core early-init
finder-inf blog-autoloads cape-autoloads clj-decompiler-autoloads
clj-refactor-autoloads cider-autoloads clojure-mode-autoloads
common-lisp-modes-autoloads consult-autoloads corfu-terminal-autoloads
corfu-autoloads csv-mode-autoloads delight-autoloads eat-autoloads
expand-region-autoloads fennel-mode-autoloads geiser-guile-autoloads
geiser-autoloads gnome-proxy-autoloads gsettings-autoloads
gvariant-autoloads inflections-autoloads isayt-autoloads
jdecomp-autoloads lsp-java-autoloads lsp-metals-autoloads
dap-mode-autoloads lsp-docker-autoloads bui-autoloads
lsp-treemacs-autoloads lsp-mode-autoloads f-autoloads
marginalia-autoloads markdown-mode-autoloads
message-view-patch-autoloads magit-autoloads pcase
magit-section-autoloads git-commit-autoloads modus-themes-autoloads
mu4e-alert-autoloads alert-autoloads log4e-autoloads gntp-autoloads
multiple-cursors-autoloads orderless-autoloads ox-hugo-autoloads
package-lint-flymake-autoloads package-lint-autoloads paredit-autoloads
parsec-autoloads parseedn-autoloads parseclj-autoloads
phi-search-autoloads popon-autoloads puni-autoloads easy-mmode
queue-autoloads racket-mode-autoloads region-bindings-autoloads
request-autoloads scala-mode-autoloads separedit-autoloads
edit-indirect-autoloads sesman-autoloads sly-autoloads spinner-autoloads
sql-indent-autoloads tomelr-autoloads transient-autoloads
treemacs-autoloads cfrs-autoloads posframe-autoloads ht-autoloads
hydra-autoloads lv-autoloads pfuture-autoloads ace-window-autoloads
avy-autoloads s-autoloads dash-autoloads vertico-autoloads
vundo-autoloads with-editor-autoloads info compat-autoloads
yaml-autoloads yaml-mode-autoloads yasnippet-autoloads
zig-mode-autoloads reformatter-autoloads package browse-url url
url-proxy url-privacy url-expand url-methods url-history url-cookie
generate-lisp-file url-domsuf url-util mailcap url-handlers url-parse
auth-source cl-seq eieio eieio-core cl-macs password-cache json subr-x
map byte-opt gv bytecomp byte-compile url-vars cl-loaddefs cl-lib rmc
iso-transl tooltip cconv eldoc paren electric uniquify ediff-hook
vc-hooks lisp-float-type elisp-mode mwheel term/pgtk-win pgtk-win
term/common-win pgtk-dnd tool-bar dnd fontset image regexp-opt fringe
tabulated-list replace newcomment text-mode lisp-mode prog-mode register
page tab-bar menu-bar rfn-eshadow isearch easymenu timer select
scroll-bar mouse jit-lock font-lock syntax font-core term/tty-colors
frame minibuffer nadvice seq simple cl-generic indonesian philippine
cham georgian utf-8-lang misc-lang vietnamese tibetan thai tai-viet lao
korean japanese eucjp-ms cp51932 hebrew greek romanian slovak czech
european ethiopic indian cyrillic chinese composite emoji-zwj charscript
charprop case-table epa-hook jka-cmpr-hook help abbrev obarray oclosure
cl-preloaded button loaddefs theme-loaddefs faces cus-face macroexp
files window text-properties overlay sha1 md5 base64 format env
code-pages mule custom widget keymap hashtable-print-readable backquote
threads xwidget-internal dbusbind inotify dynamic-setting
system-font-setting font-render-setting cairo gtk pgtk lcms2 multi-tty
move-toolbar make-network-process native-compile emacs)

Memory information:
((conses 16 871826 200523) (symbols 48 42386 0)
 (strings 32 199102 4537) (string-bytes 1 6230251) (vectors 16 63320)
 (vector-slots 8 768925 33723) (floats 8 886 2057)
 (intervals 56 2159 854) (buffers 984 19))

-- 
Andrey Listopadov