Subject: bug#79294: [PATCH] Add hideable indicators for hideshow.

From: Elijah Gabe Pérez <[email-masked]>
Date: Thu, 11 Sep 2025 21:54:30 -0600

Eli Zaretskii <[email-masked]> writes:

> Ping!  How can we make some progress in this matter?

Sorry for the delay, I have been very busy and have not had time to
respond.

I tried using `pre-redisplay-functions`, but performance was worse.

I was able to mitigate the poor performance by removing the indicators
for comment blocks (that implementation was broken and very slow) and
adding a limit for the indicators.

I also tried using the new feature in larger files for a while to test
its performance (since there were no volunteers), and from what I've
tested, it is stable.

At the moment, the implementation can now be installed.

>From 63ed5c9182abf1ca4d4c4f18b66dbd5cf38f22da Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?El=C3=ADas=20Gabriel=20P=C3=A9rez?= <[email-masked]>
Date: Sun, 17 Aug 2025 20:02:59 -0600
Subject: [PATCH] Add hideable indicators for hideshow.  (Bug#79294)

* doc/emacs/programs.texi (Hideshow): Update documentation.
* etc/NEWS: Announce changes.
* lisp/progmodes/hideshow.el (hs-show-indicators)
(hs-indicators-type, hs-indicator-maximum-buffer-size): New user
options.
(hs-indicator-hide, hs-indicator-show): New icons.
(hs-block-positions, hs--get-indicator-appearance)
(hs--make-indicators-overlays, hs--add-indicators)
(hs--refresh-indicators): New functions.
(hs-hide-block-at-point): Use hs-block-positions.
(hs-minor-mode): Rework.
---
 doc/emacs/programs.texi    |  22 +++
 etc/NEWS                   |  37 ++++-
 lisp/progmodes/hideshow.el | 279 +++++++++++++++++++++++++++++++++----
 3 files changed, 309 insertions(+), 29 deletions(-)

diff --git a/doc/emacs/programs.texi b/doc/emacs/programs.texi
index f00d79f499a..43ea263cc30 100644
--- a/doc/emacs/programs.texi
+++ b/doc/emacs/programs.texi
@@ -1730,6 +1730,9 @@ Hideshow
 
 @vindex hs-hide-comments-when-hiding-all
 @vindex hs-display-lines-hidden
+@vindex hs-show-indicators
+@vindex hs-indicator-type
+@vindex hs-indicator-maximum-buffer-size
 @vindex hs-isearch-open
 @vindex hs-special-modes-alist
   These variables can be used to customize Hideshow mode:
@@ -1743,6 +1746,25 @@ Hideshow
 If non-@code{nil}, display the number of hidden lines next to the
 ellipsis.
 
+@item hs-show-indicators
+This variable controls whether Hideshow mode should display indicators
+of hidden and shown blocks.  The indicators also allow toggling the
+hide/show state of each block. If the value is non-@code{nil}, enables
+the indicators in all blocks except comment blocks.  The default is
+@code{nil}, which disables the indicators.
+
+@item hs-indicactors-type
+This variable controls where to show the indicators, if they are
+enabled.  You can show them on the fringe (@pxref{Windows Fringes}) or
+in the window's margin.  The default is to use the fringe if it's
+available, otherwise to use the margin.
+
+@item hs-indicator-maximum-buffer-size
+This variable determines if the indicators should be enabled if the
+current buffer size is not larger than this variable value.  If set to
+@code{nil}, the indicators will be activated regardless of the buffer
+size.  The default value is 2000000 bytes (2 megabytes).
+
 @item hs-isearch-open
 This variable specifies the conditions under which incremental search
 should unhide a hidden block when matching text occurs within the
diff --git a/etc/NEWS b/etc/NEWS
index 630d03a1fa0..b03efc94d4f 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -938,9 +938,42 @@ at point to explore.
 
 ** Hideshow
 
++++
 *** New user option 'hs-display-lines-hidden'.
-If this option is non-nil, Hideshow displays the number of hidden
-lines next to the ellipsis.
+If this option is non-nil, Hideshow displays the number of hidden lines
+next to the ellipsis.  By default this is disabled.
+
++++
+*** New user option 'hs-show-indicators'.
+This user option determines if hideshow should display indicators to
+toggle the block hiding.  If non-nil, the indicators are enabled in all
+blocks except comment blocks.
+
+Otherwise if this is set to 'include-comments', the indicators will be
+displayed also in comments block.
+
+By default this is disabled.
+
+*** New user option 'hs-indicator-maximum-buffer-size'.
+This variable determines whether the indicators should be enabled if the
+current buffer size is not larger than this variable value.  If set to
+nil, the indicators will be activated regardless of the buffer size.
+The default value is 2000000 bytes (2 megabytes).
+
++++
+*** New user option 'hs-indicators-type'.
+This user option determine which indicator type should be used for the
+block indicators.
+
+The possible values can be: 'left-fringe', display the indicators in the
+left fringe (the default); 'right-fringe', display the indicators in the
+right fringe; 'left-margin', display the indicators in the left margin;
+'right-margin', display the indicators in the right margin; nil, display
+a string as indicator after the block beginning.
+
+The new icons 'hs-indicator-show' and 'hs-indicator-hide', can be used
+for customize the indicators appearance, only if 'hs-indicators-type' is
+set to 'left-margin', 'right-margin' or nil.
 
 ** C-ts mode
 
diff --git a/lisp/progmodes/hideshow.el b/lisp/progmodes/hideshow.el
index 445bdeeb7a7..9e848bc4114 100644
--- a/lisp/progmodes/hideshow.el
+++ b/lisp/progmodes/hideshow.el
@@ -220,6 +220,9 @@
 
 ;;; Code:
 (require 'mule-util) ; For `truncate-string-ellipsis'
+;; For indicators
+(require 'icons)
+(require 'fringe)
 
 ;;---------------------------------------------------------------------------
 ;; user-configurable variables
@@ -236,6 +239,16 @@ hs-ellipsis
 use that face for the ellipsis instead."
   :version "31.1")
 
+(defface hs-indicator-hide
+  '((t :inherit (shadow default)))
+  "Face used in hideshow indicator to indicate a hidden block."
+  :version "31.1")
+
+(defface hs-indicator-show
+  '((t :inherit hs-indicator-hide :weight bold))
+  "Face used in hideshow indicator to indicate a shown block."
+  :version "31.1")
+
 (defcustom hs-hide-comments-when-hiding-all t
   "Hide the comments too when you do an `hs-hide-all'."
   :type 'boolean)
@@ -265,6 +278,85 @@ hs-isearch-open
                  (const :tag "open both code and comment blocks" t)
                  (const :tag "don't open any of them" nil)))
 
+(defcustom hs-show-indicators nil
+  "Whether hideshow should display block hide/show indicators.
+If non-nil, hideshow will display indicators for toggling the
+visibility of a block everywhere except in comment blocks.
+
+The indicators appearance are specified in `hs-indicator-type' (which see)."
+  :type 'boolean
+  :version "31.1")
+
+(defcustom hs-indicator-type 'fringe
+  "Indicate which indicator type to use for the block indicators.
+
+The possible values can be:
+
+ - `fringe', display the indicators in the fringe.
+ - `margin', display the indicators in the margin.
+ - nil, display a string as indicator after the block beginning.
+
+This only have effect if `hs-show-indicators' is non-nil."
+  :type '(choice
+          (const :tag "Fringes" fringe)
+          (const :tag "Margins" margin)
+          (const :tag "String after block beginning." nil))
+  :version "31.1")
+
+(defcustom hs-indicator-maximum-buffer-size 2000000 ;2mb
+  "Max buffer size in bytes where the indicators should be enabled.
+If current buffer is larger than this variable value, the indicators
+will be disabled.
+
+If set to nil, the indicators will be activated regardless of the buffer
+size."
+  :type '(choice natnum (const :tag "No limit" nil))
+  :version "31.1")
+
+(define-fringe-bitmap
+  'hs-hide
+  [#b0000000
+   #b1000001
+   #b1100011
+   #b0110110
+   #b0011100
+   #b0001000
+   #b0000000])
+
+(define-fringe-bitmap
+  'hs-show
+  [#b0110000
+   #b0011000
+   #b0001100
+   #b0000110
+   #b0001100
+   #b0011000
+   #b0110000])
+
+(define-icon hs-indicator-hide nil
+  `((image "outline-open.svg" "outline-open.pbm"
+           :face hs-indicator-hide
+           :height (0.6 . em)
+           :ascent center)
+    (symbol "🞃" "▼")
+    (text "-"))
+  "Icon used for hide block at point.
+This is only used if `hs-indicator-type' is set to `left-margin',
+`right-margin' or nil."
+  :version "31.1")
+
+(define-icon hs-indicator-show nil
+  `((image "outline-close.svg" "outline-close.pbm"
+           :face hs-indicator-show
+           :height (0.6 . em)
+           :ascent center)
+    (symbol "▸" "▶")
+    (text "+"))
+  "Icon used for show block at point.
+This is only used if `hs-indicator-type' is set to `left-margin',
+`right-margin' or nil."
+  :version "31.1")
+
 ;;;###autoload
 (defvar hs-special-modes-alist
   ;; FIXME: Currently the check is made via
@@ -562,6 +654,137 @@ hs-make-overlay
     (when hs-set-up-overlay (funcall hs-set-up-overlay ov))
     ov))
 
+(defun hs-block-positions (&optional beg)
+  "Return block positions.
+If BEG is defined, start from BEG."
+  (save-match-data
+    (save-excursion
+      (if beg (goto-char beg))
+      (when (funcall hs-looking-at-block-start-p-func)
+        (let ((mdata (match-data t))
+              (header-end (match-end 0))
+              block-beg block-end)
+          ;; `block-start' is the point at the end of the block
+          ;; beginning, which may need to be adjusted
+          (save-excursion
+            (goto-char (funcall (or hs-adjust-block-beginning #'identity)
+                                header-end))
+            (setq block-beg (line-end-position)))
+          ;; `block-end' is the point at the end of the block
+          (hs-forward-sexp mdata 1)
+          (setq block-end
+                (cond ((and (stringp hs-block-end-regexp)
+                            (looking-back hs-block-end-regexp nil))
+                       (match-beginning 0))
+                      ((functionp hs-block-end-regexp)
+                       (funcall hs-block-end-regexp)
+                       (match-beginning 0))
+                      (t (point))))
+          (cons block-beg block-end))))))
+
+(defun hs--get-indicator-appearance ()
+  (if (hs-overlay-at (pos-eol))
+      [hs-show hs-indicator-show hs-indicator-show]
+    [hs-hide hs-indicator-hide hs-indicator-hide]))
+
+(defun hs--make-indicators-overlays (beg end)
+  "Helper function for make the indicators overlays."
+  (let* ((o (make-overlay
+             ;; FIXME: The mouse-1 event doesn't work well for fringes,
+             ;; a workaround for this is set the overlay at the
+             ;; beginning of the line only for fringe indicators
+             (if (eq hs-indicator-type 'fringe) (pos-bol) beg)
+             (1+ beg)))
+         (map (make-sparse-keymap))
+         (indicator (hs--get-indicator-appearance)))
+
+    (when (and (< beg end) (not (= (line-number-at-pos beg) (line-number-at-pos end))))
+      (define-key map `[,@(append
+                           (pcase hs-indicator-type
+                             ('fringe '(left-fringe))
+                             ('margin '(left-margin)))
+                           '(mouse-1))]
+                  (lambda ()
+                    (interactive)
+                    ;; Goto block beginning before calling
+                    ;; `hs-toggle-hiding'
+                    (goto-char beg)
+                    (hs-toggle-hiding)))
+
+      (overlay-put o 'hs-indicator t)
+      (overlay-put o 'evaporate t)
+      (overlay-put o 'priority -50)
+
+      (pcase hs-indicator-type
+        ;; Fringes
+        ('fringe (overlay-put
+                  o 'before-string
+                  (propertize
+                   "+" 'display
+                   `(left-fringe ,(aref indicator 0) ,(aref indicator 2))))
+                 (overlay-put o 'keymap map))
+        ;; Margins
+        ('margin (overlay-put
+                  o 'before-string
+                  (propertize
+                   "+" 'display
+                   `((margin left-margin)
+                     ,(or (plist-get (icon-elements (aref indicator 1)) 'image)
+                          (propertize
+                           (plist-get (icon-elements (aref indicator 1)) 'string)
+                           'rear-nonsticky nil
+                           'face (aref indicator 2)
+                           'keymap map)))
+                   'rear-nonsticky nil
+                   'face (aref indicator 2)
+                   'keymap map)))
+        ;; Plain string
+        ('nil (overlay-put
+               o 'after-string
+               (propertize
+                (icon-string (aref indicator 1))
+                'face (aref 2 indicator)
+                'mouse-face 'highlight
+                'keymap map)))))))
+
+(defun hs--add-indicators (&optional beg end)
+  "Add hideable indicators from BEG to END."
+  (save-excursion
+    (setq beg (if (null beg) (window-start) (goto-char beg) (pos-bol))
+          end (if (null end) (window-end) (goto-char end) (pos-bol))))
+  (goto-char beg)
+  (remove-overlays beg end 'hs-indicator t)
+
+  (while (funcall hs-find-next-block-func hs-block-start-regexp end nil)
+    (when-let* ((b-beg (match-beginning 0))
+                ((save-excursion
+                   (goto-char b-beg)
+                   (funcall hs-looking-at-block-start-p-func)))
+                ;; Check if block is longer than 1 line,
+                ;; `catch' is used here if the search fail due
+                ;; unbalanced parenthesis or any other unknown error
+                ;; caused in `hs-forward-sexp'.
+                (b-end (catch 'hs-indicator-error
+                         (save-excursion
+                           (goto-char b-beg)
+                           (condition-case _
+                               (funcall hs-forward-sexp-func 1)
+                             (scan-error (throw 'hs-indicator-error nil)))
+                           (point)))))
+      (hs--make-indicators-overlays b-beg b-end))
+    ;; Only 1 indicator per line if `hs-indicator-type' is not nil.
+    (if hs-indicator-type (forward-line 1)))
+  `(jit-lock-bounds ,beg . ,end))
+
+(defun hs--refresh-indicators ()
+  "Update indicators appearance at current block."
+  (when hs-show-indicators
+    (save-match-data
+      (save-excursion
+        ;; Using window-start and window-end is more faster
+        ;; than computing again the block positions
+        (hs--add-indicators (window-start) (window-end))))))
+
 (defun hs--get-ellipsis (b e)
   "Helper function for `hs-make-overlay'.
 This returns the ellipsis string to use and its face."
@@ -669,32 +892,19 @@ hs-hide-block-at-point
 and then further adjusted to be at the end of the line."
   (if comment-reg
       (hs-hide-comment-region (car comment-reg) (cadr comment-reg) end)
-    (when (funcall hs-looking-at-block-start-p-func)
-      (let ((mdata (match-data t))
-            (header-end (match-end 0))
-            p q ov)
-	;; `p' is the point at the end of the block beginning, which
-	;; may need to be adjusted
-	(save-excursion
-	  (goto-char (funcall (or hs-adjust-block-beginning #'identity)
-			      header-end))
-	  (setq p (line-end-position)))
-	;; `q' is the point at the end of the block
-	(hs-forward-sexp mdata 1)
-	(setq q (cond ((and (stringp hs-block-end-regexp)
-                            (looking-back hs-block-end-regexp nil))
-		       (match-beginning 0))
-                      ((functionp hs-block-end-regexp)
-                       (funcall hs-block-end-regexp)
-                       (match-beginning 0))
-		      (t (point))))
-        (when (and (< p q) (> (count-lines p q) 1))
-          (cond ((and hs-allow-nesting (setq ov (hs-overlay-at p)))
-                 (delete-overlay ov))
-                ((not hs-allow-nesting)
-                 (hs-discard-overlays p q)))
-          (hs-make-overlay p q 'code (- header-end p)))
-        (goto-char (if end q (min p header-end)))))))
+    (let* ((block (hs-block-positions))
+           (p (car-safe block))
+           (q (cdr-safe block))
+           ov)
+      (if (and block (< p q) (> (count-lines p q) 1))
+          (progn
+            (cond ((and hs-allow-nesting (setq ov (hs-overlay-at p)))
+                   (delete-overlay ov))
+                  ((not hs-allow-nesting)
+                   (hs-discard-overlays p q)))
+            (goto-char q)
+            (hs-make-overlay p q 'code (- (match-end 0) p)))
+        (goto-char (if end q (min p (match-end 0))))))))
 
 (defun hs-inside-comment-p ()
   "Return non-nil if point is inside a comment, otherwise nil.
@@ -976,6 +1186,7 @@ hs-hide-block
 	   (funcall hs-looking-at-block-start-p-func)
            (funcall hs-find-block-beginning-func))
        (hs-hide-block-at-point end c-reg)
+       (hs--refresh-indicators)
        (run-hooks 'hs-hide-hook))))))
 
 (defun hs-show-block (&optional end)
@@ -1011,6 +1222,7 @@ hs-show-block
       (when (and p q)
         (hs-discard-overlays p q)
         (goto-char (if end q (1+ p))))))
+   (hs--refresh-indicators)
    (run-hooks 'hs-show-hook)))
 
 (defun hs-hide-level (arg)
@@ -1086,8 +1298,21 @@ hs-minor-mode
                   #'turn-off-hideshow
                   nil t)
         (setq-local line-move-ignore-invisible t)
-        (add-to-invisibility-spec '(hs . t)))
+        (add-to-invisibility-spec '(hs . t))
+        ;; Add block indicators
+        (when (and hs-show-indicators
+                   (or (and (integerp hs-indicator-maximum-buffer-size)
+                            (< (buffer-size) hs-indicator-maximum-buffer-size))
+                       (not hs-indicator-maximum-buffer-size)))
+          (when (and (not (display-graphic-p))
+                     (eq hs-indicator-type 'fringe))
+            (setq-local hs-indicator-type 'margin))
+          (jit-lock-register #'hs--add-indicators)))
+
     (remove-from-invisibility-spec '(hs . t))
+    (when hs-show-indicators
+      (jit-lock-unregister #'hs--add-indicators)
+      (remove-overlays nil nil 'hs-indicator t))
     ;; hs-show-all does nothing unless h-m-m is non-nil.
     (let ((hs-minor-mode t))
       (hs-show-all))))
-- 
2.51.0

-- 
- E.G via Gnus and Org.