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

From: Elijah Gabe Pérez <[email-masked]>
Date: Fri, 22 Aug 2025 22:56:33 -0600

This patch adds indicators to toggle source block visibility (as shown
in the emacs-devel thread).

Currently the implementation makes scrolling a bit slow, due to the
computation required to get the hideable blocks.

I'm not sure if there is a way to add the indicators lazily or a better
solution.

>From 2d8f1d84bc0eba4fa3d8fb89ec953e43ba43e7bb 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#)

* doc/emacs/programs.texi (Hideshow): Update documentation.
* etc/NEWS: Announce changes.
* lisp/progmodes/hideshow.el (hs-show-indicators)
(hs-indicators-type): New user options.
(hs-indicator-hide, hs-indicator-show): New icons.
(hs-discard-indicator-overlays, hs-block-positions)
(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    |  19 +++
 etc/NEWS                   |  27 ++++
 lisp/progmodes/hideshow.el | 305 +++++++++++++++++++++++++++++++++----
 3 files changed, 324 insertions(+), 27 deletions(-)

diff --git a/doc/emacs/programs.texi b/doc/emacs/programs.texi
index f00d79f499a..6df97a82818 100644
--- a/doc/emacs/programs.texi
+++ b/doc/emacs/programs.texi
@@ -1730,6 +1730,8 @@ Hideshow
 
 @vindex hs-hide-comments-when-hiding-all
 @vindex hs-display-lines-hidden
+@vindex hs-show-indicators
+@vindex hs-indicators-type
 @vindex hs-isearch-open
 @vindex hs-special-modes-alist
   These variables can be used to customize Hideshow mode:
@@ -1743,6 +1745,23 @@ Hideshow
 If non-@code{nil}, display the number of hidden lines next to the
 ellipsis.
 
+@item hs-show-indicators
+This variable specifies if hideshow should display indicators to toggle
+the block hiding.  If this is set to non-@code{nil}, the indicators are
+enabled except in comments, otherwise, if set to
+@code{include-comments}, the indicators will be displayed also in
+comments.
+
+@item hs-indicactors-type
+This variable specifies which indicator type to use.  Its value should
+be either @code{left-fringe} (display the indicators in the left
+fringe), @code{right-fringe} (display the indicators in the right
+fringe), @code{left-margin} (display the indicators in the left margin),
+@code{right-margin} (display the indicators in the right margin), or
+@code{nil} (display a string as indicator after the block beginning).
+If @code{left-fringe} or @code{right-fringe} are selected and current
+Emacs session does not support fringes, margins will be used instead.
+
 @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 e94464b203b..1f608170884 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -895,10 +895,37 @@ 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.
 
++++
+*** 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 except
+in comments.
+
+Otherwise if this is set to 'include-comments', the indicators will be
+displayed also in comments.
+
+By default this is disabled.
+
++++
+*** 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..4407cb78b94 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 hide current block."
+  :version "31.1")
+
+(defface hs-indicator-show
+  '((t :inherit hs-indicator-hide :weight bold))
+  "Face used in hideshow indicator to show current 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,87 @@ 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 indicators.
+If non-nil, hideshow will display indicators for toggle the
+visibility of a block.
+
+If set to `include-comments', the indicators are displayed also in
+comments.
+
+The indicators appearance are specified in `hs-indicators-type' (which see)."
+  :type '(choice boolean (const :tag "Include comments" include-comments))
+  :version "31.1")
+
+(defcustom hs-indicators-type 'left-fringe
+  "Indicate which indicator type to use for the block indicators.
+
+The possible values can be:
+
+ - `left-fringe', display the indicators in the left fringe.
+ - `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.
+
+If `left-fringe' or `right-fringe' are selected and fringes are not
+supported, margins will be used instead.
+
+This only have effect if `hs-show-indicators' is non-nil."
+  :type '(choice
+          (const :tag "Left fringe" left-fringe)
+          (const :tag "Right fringe" right-fringe)
+          (const :tag "Left margin" left-margin)
+          (const :tag "Right margin" right-margin)
+          (const :tag "String after block beginning." 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-indicators-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-indicators-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
@@ -506,6 +600,17 @@ hs-headline
 
 Note that `mode-line-format' is buffer-local.")
 
+(defvar hs--indicators-rx
+  `((hs-discard-indicator-overlays)
+    (,(lambda (bound)
+        (funcall hs-find-next-block-func
+                 hs-block-start-regexp
+                 bound
+                 (unless (eq hs-show-indicators 'exclude-comments)
+                   t)))
+     0 (hs--add-indicators)))
+  "Hideshow indicator regexp for `font-lock-keywords'.")
+
 ;;---------------------------------------------------------------------------
 ;; support functions
 
@@ -524,6 +629,11 @@ hs-discard-overlays
       (when (overlay-get ov 'hs)
         (delete-overlay ov)))))
 
+(defun hs-discard-indicator-overlays (limit)
+  "Remove hideshow indicator overlays from point to LIMIT."
+  (remove-overlays (point) limit 'hs-indicator t)
+  nil)
+
 (defun hs-make-overlay (b e kind &optional b-offset e-offset)
   "Return a new overlay in region defined by B and E with type KIND.
 KIND is either `code' or `comment'.  Optional fourth arg B-OFFSET
@@ -562,6 +672,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))
+              p q)
+          ;; `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))))
+          (cons p q))))))
+
+(defun hs--add-indicators ()
+  "Add block indicators."
+  ;; Using `get-text-property' here should be faster than using
+  ;; `syntax-ppss'
+  (unless (memq (get-text-property (match-beginning 0) 'face)
+                '(font-lock-string-face font-lock-doc-face))
+    (let* ((beg (match-beginning 0))
+           ;; Check if block is already hidden, we
+           ;; cannot call `hs-already-hidden-p'
+           ;; because we are already at the block
+           ;; beginning
+           (hidden-p (hs-overlay-at (line-end-position)))
+           (ov (make-overlay
+                ;; FIXME: The mouse-1 event doesn't work well for
+                ;; fringes, a workaround for this is set the overlay
+                ;; from the beginning of the line only if fringes are
+                ;; going to be used as indicators.
+                (if (memq hs-indicators-type '(left-fringe right-fringe))
+                    (line-beginning-position)
+                  beg)
+                (match-end 0)))
+           (map (make-sparse-keymap))
+           (indicator (if hidden-p
+                          `(hs-show hs-indicator-show hs-indicator-show)
+                        `(hs-hide hs-indicator-hide hs-indicator-hide)))
+           (commentp (when (and (eq hs-show-indicators 'include-comments))
+                       (hs-inside-comment-p))))
+
+      (when (or (and commentp (> (count-lines (car commentp) (nth 1 commentp)) 1))
+                ;; Check if block is longer than 1 line,
+                ;; `ignore-errors' is used here if the search fail due
+                ;; unbalanced parenthesis or any other unknown error
+                ;; caused in `hs-forward-sexp'.
+                (and-let* ((block (ignore-errors
+                                    (hs-block-positions beg)))
+                           (p (car-safe block))
+                           (q (cdr-safe block))
+                           ((< p q))
+                           ((> (count-lines p q) 1)))))
+        (overlay-put ov 'hs-indicator t)
+        (overlay-put ov 'evaporate t)
+
+        (define-key map
+                    `[,@(if hs-indicators-type
+                            `(,hs-indicators-type mouse-1)
+                          '(mouse-1))]
+                    (lambda ()
+                      (interactive)
+                      ;; Goto block beginning before calling
+                      ;; `hs-toggle-hiding'
+                      (goto-char beg)
+                      (hs-toggle-hiding)))
+
+        (pcase hs-indicators-type
+          ;;;;;;;;;;;;;;;;;;;;
+          ;; Fringes
+          ((or 'left-fringe 'right-fringe)
+           (overlay-put
+            ov 'before-string
+            (propertize
+             "+" 'display
+             `(,hs-indicators-type
+               ,(car indicator) ,(nth 2 indicator))))
+           (overlay-put ov 'keymap map))
+          ;;;;;;;;;;;;;;;;;;;;
+          ;; Margins
+          ((or 'left-margin 'right-margin)
+           (overlay-put
+            ov 'before-string
+            (propertize
+             "+" 'display
+             `((margin ,hs-indicators-type)
+               ,(or (plist-get (icon-elements (nth 1 indicator)) 'image)
+                    (propertize
+                     (plist-get (icon-elements (nth 1 indicator)) 'string)
+                     'rear-nonsticky nil
+                     'face (nth 2 indicator)
+                     'keymap map)))
+             'rear-nonsticky nil
+             'face (nth 2 indicator)
+             'keymap map)))
+          ;;;;;;;;;;;;;;;;;;;;
+          ;; Plain string
+          ('nil
+           (overlay-put
+            ov 'after-string
+            (propertize
+             (icon-string (nth 1 indicator))
+             'face (nth 2 indicator)
+             'mouse-face 'highlight
+             'keymap map))))
+
+        ;; Only 1 indicator per line if `hs-indicators-type' is not nil.
+        (if hs-indicators-type
+            (goto-char (line-end-position))))))
+  nil)
+
+(defun hs--refresh-indicators ()
+  "Update indicators appearance."
+  (let* ((block (hs-block-positions (1- (point))))
+         (beg (car block))
+         (end (cdr block)))
+    (font-lock-flush beg 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 +910,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.
@@ -1086,8 +1314,31 @@ 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 hs-show-indicators
+          (when-let* ((type
+                       (if (not (display-graphic-p))
+                           'left-margin hs-indicators-type)))
+            (setq-local
+             hs-indicators-type
+             (cond
+              ((not (eq (current-bidi-paragraph-direction) 'right-to-left)) type)
+              ((eq hs-indicators-type 'left-fringe) 'right-fringe)
+              ((eq hs-indicators-type 'left-margin) 'right-margin)
+              (t hs-indicators-type))))
+
+          (font-lock-add-keywords nil hs--indicators-rx)
+          (add-hook 'hs-show-hook #'hs--refresh-indicators nil t)
+          (add-hook 'hs-hide-hook #'hs--refresh-indicators nil t)
+          (font-lock-flush)))
     (remove-from-invisibility-spec '(hs . t))
+    (when hs-show-indicators
+      (font-lock-remove-keywords nil hs--indicators-rx)
+      (remove-hook 'hs-show-hook #'hs--refresh-indicators t)
+      (remove-hook 'hs-hide-hook #'hs--refresh-indicators t)
+      (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.50.1

-- 
- E.G via Gnus and Org.