Subject: bug#76018: 31.0.50; wrap-prefix properties from visual-wrap-prefix-mode proliferate

From: Jim Porter <[email-masked]>
Date: Mon, 26 May 2025 18:30:34 -0700

On 5/26/2025 5:11 PM, Jim Porter wrote:
> Eli, what do you think? I've also moved an internal Eshell utility 
> function ('eshell--append-text-property') to subr-x.el, since it would 
> be useful for the visual-wrap code. I've updated the manual and NEWS to 
> mention this function as well so that others can use it if they like.

After some further thought, I noticed that 'visual-wrap-prefix-mode' 
doesn't clean up after itself properly. Attached is a fix, taking 
advantage of a new function called 'remove-display-text-properties', 
which does what you might expect.

From 03315fb37c88c58972af90fffe79bfbc3f333c56 Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Mon, 26 May 2025 16:57:25 -0700
Subject: [PATCH 1/2] Make 'wrap-prefix' nonsticky when using
 'visual-wrap-prefix-mode'

* lisp/eshell/em-prompt.el (eshell--append-text-property): Move from
here...
* lisp/emacs-lisp/subr-x.el (append-text-property): ... to here.  Update
callers.

* lisp/visual-wrap.el (visual-wrap--apply-to-line): Use
'append-text-property'.

* test/lisp/visual-wrap-tests.el (visual-wrap-tests/simple)
(visual-wrap-tests/safe-display)
(visual-wrap-tests/unsafe-display/within-line): Update tests.
(visual-wrap-tests/wrap-prefix-stickiness): New test.

* doc/lispref/text.texi (Changing Properties): Document
'append-text-property'.

* etc/NEWS: Announce 'append-text-property' (bug#76018).
---
 doc/lispref/text.texi          |  7 +++++
 etc/NEWS                       |  6 +++++
 lisp/emacs-lisp/subr-x.el      | 14 ++++++++++
 lisp/eshell/em-prompt.el       | 17 ++----------
 lisp/visual-wrap.el            |  9 ++++++-
 test/lisp/visual-wrap-tests.el | 48 ++++++++++++++++++++++++++++------
 6 files changed, 77 insertions(+), 24 deletions(-)

diff --git a/doc/lispref/text.texi b/doc/lispref/text.texi
index 75b2b1c3d60..471ba29eba2 100644
--- a/doc/lispref/text.texi
+++ b/doc/lispref/text.texi
@@ -3137,6 +3137,13 @@ Changing Properties
 @end example
 @end defun
 
+@defun append-text-property start end prop values &optional object
+This function appends a list of @var{values} to the @var{prop} property
+for the text between @var{start} and @var{end} in the string or buffer
+@var{object}.  If @var{object} is @code{nil}, it defaults to the current
+buffer.
+@end defun
+
 @defun remove-text-properties start end props &optional object
 This function deletes specified text properties from the text between
 @var{start} and @var{end} in the string or buffer @var{object}.  If
diff --git a/etc/NEWS b/etc/NEWS
index 33b042720b5..e0788b421a1 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -2339,6 +2339,12 @@ This 'display' property was previously supported only as text property.
 Now overlays can also have this property, with the same effect for the
 text "covered" by the overlay.
 
++++
+** New function 'append-text-property'.
+This function appends a list of values to a text property for the
+specified region of text, preserving any values already set for that
+property.
+
 +++
 ** New macro 'cond*'.
 The new macro 'cond*' is an alternative to 'cond' and 'pcase'.
diff --git a/lisp/emacs-lisp/subr-x.el b/lisp/emacs-lisp/subr-x.el
index eaa8119ead7..77738265f25 100644
--- a/lisp/emacs-lisp/subr-x.el
+++ b/lisp/emacs-lisp/subr-x.el
@@ -416,6 +416,20 @@ string-glyph-split
         (setq start (1+ start))))
     (nreverse result)))
 
+;;;###autoload
+(defun append-text-property (start end prop values &optional object)
+  "Append a list of values to a text property from START to END.
+PROP is the text property to append to, and VALUES is the list of
+property values to append.  OBJECT is the object to propertize, as with
+`put-text-property' (which see)."
+  (let (next)
+    (while (< start end)
+      (setq next (next-single-property-change start prop object end))
+      (put-text-property start next prop
+                         (append (get-text-property start prop object) values)
+                         object)
+      (setq start next))))
+
 ;;;###autoload
 (defun add-display-text-property (start end prop value
                                         &optional object)
diff --git a/lisp/eshell/em-prompt.el b/lisp/eshell/em-prompt.el
index 1520045d063..65768de82bb 100644
--- a/lisp/eshell/em-prompt.el
+++ b/lisp/eshell/em-prompt.el
@@ -119,19 +119,6 @@ eshell-prompt-initialize
     (add-hook 'eshell-post-command-hook 'eshell-emit-prompt nil t)
     (eshell-prompt-mode)))
 
-(defun eshell--append-text-property (start end prop value &optional object)
-  "Append to a text property from START to END.
-PROP is the text property to append to, and VALUE is the list of
-property values to append.  OBJECT is the object to propertize, as with
-`put-text-property' (which see)."
-  (let (next)
-    (while (< start end)
-      (setq next (next-single-property-change start prop object end))
-      (put-text-property start next prop
-                         (append (get-text-property start prop object) value)
-                         object)
-      (setq start next))))
-
 (defun eshell-emit-prompt ()
   "Emit a prompt if eshell is being used interactively."
   (when (boundp 'ansi-color-context-region)
@@ -147,8 +134,8 @@ eshell-emit-prompt
         (add-text-properties
          0 len '(read-only t font-lock-face eshell-prompt) prompt)
         (setq sticky-props `(read-only font-lock-face . ,sticky-props)))
-      (eshell--append-text-property 0 len 'front-sticky sticky-props prompt)
-      (eshell--append-text-property 0 len 'rear-nonsticky sticky-props prompt)
+      (append-text-property 0 len 'front-sticky sticky-props prompt)
+      (append-text-property 0 len 'rear-nonsticky sticky-props prompt)
       (eshell-interactive-filter nil prompt)))
   (run-hooks 'eshell-after-prompt-hook))
 
diff --git a/lisp/visual-wrap.el b/lisp/visual-wrap.el
index b921e1f0549..ce397e6914f 100644
--- a/lisp/visual-wrap.el
+++ b/lisp/visual-wrap.el
@@ -33,6 +33,8 @@
 
 ;;; Code:
 
+(require 'text-property-search)
+
 (defcustom visual-wrap-extra-indent 0
   "Number of extra spaces to indent in `visual-wrap-prefix-mode'.
 
@@ -165,7 +167,12 @@ visual-wrap--apply-to-line
        position (pos-eol) 'wrap-prefix
        (if (numberp next-line-prefix)
            `(space :align-to (,next-line-prefix . width))
-         next-line-prefix)))))
+         next-line-prefix))
+      ;; Make sure that when typing at the end of a line with
+      ;; `wrap-prefix' set, we don't continue that property over
+      ;; subsequent lines.  See bug#76018.
+      (append-text-property position (pos-eol)
+                            'rear-nonsticky '(wrap-prefix)))))
 
 (defun visual-wrap--content-prefix (prefix position)
   "Get the next-line prefix for the specified first-line PREFIX.
diff --git a/test/lisp/visual-wrap-tests.el b/test/lisp/visual-wrap-tests.el
index 04977afe207..d3faa128637 100644
--- a/test/lisp/visual-wrap-tests.el
+++ b/test/lisp/visual-wrap-tests.el
@@ -37,11 +37,15 @@ visual-wrap-tests/simple
              (buffer-string)
              #("greetings\n* hello\n* hi"
                10 12 ( wrap-prefix (space :align-to (2 . width))
-                       display (min-width ((2 . width))))
-               12 17 ( wrap-prefix (space :align-to (2 . width)))
+                       display (min-width ((2 . width)))
+                       rear-nonsticky (wrap-prefix))
+               12 17 ( wrap-prefix (space :align-to (2 . width))
+                       rear-nonsticky (wrap-prefix))
                18 20 ( wrap-prefix (space :align-to (2 . width))
-                       display (min-width ((2 . width))))
-               20 22 ( wrap-prefix (space :align-to (2 . width))))))))
+                       display (min-width ((2 . width)))
+                       rear-nonsticky (wrap-prefix))
+               20 22 ( wrap-prefix (space :align-to (2 . width))
+                       rear-nonsticky (wrap-prefix)))))))
 
 (ert-deftest visual-wrap-tests/safe-display ()
   "Test adding wrapping properties to text with safe display properties."
@@ -52,9 +56,11 @@ visual-wrap-tests/safe-display
              (buffer-string)
              #("* hello"
                0 2 ( wrap-prefix (space :align-to (2 . width))
-                     display (min-width ((2 . width))))
+                     display (min-width ((2 . width)))
+                     rear-nonsticky (wrap-prefix))
                2 7 ( wrap-prefix (space :align-to (2 . width))
-                     display (raise 1)))))))
+                     display (raise 1)
+                     rear-nonsticky (wrap-prefix)))))))
 
 (ert-deftest visual-wrap-tests/unsafe-display/within-line ()
   "Test adding wrapping properties to text with unsafe display properties.
@@ -67,9 +73,11 @@ visual-wrap-tests/unsafe-display/within-line
              (buffer-string)
              #("* [img]"
                0 2 ( wrap-prefix (space :align-to (2 . width))
-                     display (min-width ((2 . width))))
+                     display (min-width ((2 . width)))
+                     rear-nonsticky (wrap-prefix))
                2 7 ( wrap-prefix (space :align-to (2 . width))
-                     display (image :type bmp)))))))
+                     display (image :type bmp)
+                     rear-nonsticky (wrap-prefix)))))))
 
 (ert-deftest visual-wrap-tests/unsafe-display/spanning-lines ()
   "Test adding wrapping properties to text with unsafe display properties.
@@ -117,4 +125,28 @@ visual-wrap-tests/unsafe-display/multiple-2
               0 4 (display ((image :type bmp)))
               4 8 (display ((image :type bmp) (height 1.5))))))))
 
+(ert-deftest visual-wrap-tests/wrap-prefix-stickiness ()
+  "Test that `wrap-prefix' doesn't persist across multiple lines when typing.
+See bug#76018."
+  (with-temp-buffer
+    (insert "* this zoo contains goats")
+    (visual-wrap-prefix-function (point-min) (point-max))
+    (should (equal-including-properties
+             (buffer-string)
+             #("* this zoo contains goats"
+               0  2 ( wrap-prefix (space :align-to (2 . width))
+                      display (min-width ((2 . width)))
+                      rear-nonsticky (wrap-prefix))
+               2 25 ( wrap-prefix (space :align-to (2 . width))
+                      rear-nonsticky (wrap-prefix)))))
+    (insert-and-inherit "\n\nit also contains pandas")
+    (should (equal-including-properties
+             (buffer-string)
+             #("* this zoo contains goats\n\nit also contains pandas"
+               0  2 ( wrap-prefix (space :align-to (2 . width))
+                      display (min-width ((2 . width)))
+                      rear-nonsticky (wrap-prefix))
+               2 25 ( wrap-prefix (space :align-to (2 . width))
+                      rear-nonsticky (wrap-prefix)))))))
+
 ;; visual-wrap-tests.el ends here
-- 
2.25.1

From b398fd7ee3d84a4eb78ef20336aa22e2bf42867d Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Mon, 26 May 2025 18:26:19 -0700
Subject: [PATCH 2/2] Properly clean up after deactivating
 'visual-wrap-prefix-mode'

* lisp/emacs-lisp/subr-x.el (add-remove--display-text-property): New
function, extracted from...
(add-display-text-property): ... here.
(remove-display-text-property): New function.

* lisp/visual-wrap.el (visual-wrap--remq-text-property): New function.
(visual-wrap--apply-to-line, visual-wrap-prefix-function): Add REMOVE
argument.
(visual-wrap-prefix-mode): Call 'visual-wrap-prefix-function' to clean
up.

* test/lisp/visual-wrap-tests.el (visual-wrap-tests/cleanup): New test.

* doc/lispref/display.texi (Display Property): Document
'remove-display-text-property'.

* etc/NEWS: Announce 'remove-display-text-property'.
---
 doc/lispref/display.texi       | 32 ++++++++++++++
 etc/NEWS                       |  6 +++
 lisp/emacs-lisp/subr-x.el      | 57 ++++++++++++++++--------
 lisp/visual-wrap.el            | 81 +++++++++++++++++++++++-----------
 test/lisp/visual-wrap-tests.el | 15 +++++++
 5 files changed, 147 insertions(+), 44 deletions(-)

diff --git a/doc/lispref/display.texi b/doc/lispref/display.texi
index 3b48cb93405..77a53948b5e 100644
--- a/doc/lispref/display.texi
+++ b/doc/lispref/display.texi
@@ -5299,6 +5299,38 @@ Display Property
 If @code{nil}, this defaults to the current buffer.
 @end defun
 
+@defun remove-display-text-property start end prop &optional object
+Remove @code{display} property @var{prop} from the text from @var{start}
+to @var{end}.
+
+If any text in the region has any other @code{display} properties, those
+properties are retained.  For instance:
+
+@lisp
+(add-display-text-property 1 8 'raise 0.5)
+(add-display-text-property 4 8 'height 2.0)
+(remove-display-text-property 2 6 'raise)
+@end lisp
+
+After doing this, the text will have the following @code{display}
+properties:
+
+@itemize @bullet
+@item
+The region from 1 to 2, only @code{raise}
+
+@item
+The region from 2 to 4, no properties
+
+@item
+The region from 4 to 6, only @code{height}
+
+@item
+The region from 6 to 8, both @code{raise} and @code{height}
+
+@end itemize
+@end defun
+
 @cindex display property, unsafe evaluation
 @cindex security, and display specifications
   Some of the display specifications allow inclusion of Lisp forms,
diff --git a/etc/NEWS b/etc/NEWS
index e0788b421a1..369bbc2b347 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -2345,6 +2345,12 @@ This function appends a list of values to a text property for the
 specified region of text, preserving any values already set for that
 property.
 
++++
+** New function 'remove-display-text-property'.
+This function removes a display property from the specified region of
+text, preserving any other display properties already set for that
+region.
+
 +++
 ** New macro 'cond*'.
 The new macro 'cond*' is an alternative to 'cond' and 'pcase'.
diff --git a/lisp/emacs-lisp/subr-x.el b/lisp/emacs-lisp/subr-x.el
index 77738265f25..77c7a5bba7c 100644
--- a/lisp/emacs-lisp/subr-x.el
+++ b/lisp/emacs-lisp/subr-x.el
@@ -430,27 +430,22 @@ append-text-property
                          object)
       (setq start next))))
 
-;;;###autoload
-(defun add-display-text-property (start end prop value
-                                        &optional object)
-  "Add display property PROP with VALUE to the text from START to END.
-If any text in the region has a non-nil `display' property, those
-properties are retained.
-
-If OBJECT is non-nil, it should be a string or a buffer.  If nil,
-this defaults to the current buffer."
+(defun add-remove--display-text-property (start end prop value
+                                                &optional object remove)
   (let ((sub-start start)
         (sub-end 0)
+        (limit (if (stringp object)
+                   (min (length object) end)
+                 (min end (point-max))))
         disp)
     (while (< sub-end end)
       (setq sub-end (next-single-property-change sub-start 'display object
-                                                 (if (stringp object)
-                                                     (min (length object) end)
-                                                   (min end (point-max)))))
+                                                 limit))
       (if (not (setq disp (get-text-property sub-start 'display object)))
           ;; No old properties in this range.
-          (put-text-property sub-start sub-end 'display (list prop value)
-                             object)
+          (unless remove
+            (put-text-property sub-start sub-end 'display (list prop value)
+                               object))
         ;; We have old properties.
         (let ((vector nil))
           ;; Make disp into a list.
@@ -466,13 +461,37 @@ add-display-text-property
           ;; Remove any old instances.
           (when-let* ((old (assoc prop disp)))
             (setq disp (delete old disp)))
-          (setq disp (cons (list prop value) disp))
-          (when vector
-            (setq disp (seq-into disp 'vector)))
-          ;; Finally update the range.
-          (put-text-property sub-start sub-end 'display disp object)))
+          (unless remove
+            (setq disp (cons (list prop value) disp)))
+          (if (not disp)
+              (remove-text-properties sub-start sub-end '(display nil) object)
+            (when vector
+              (setq disp (seq-into disp 'vector)))
+            ;; Finally update the range.
+            (put-text-property sub-start sub-end 'display disp object))))
       (setq sub-start sub-end))))
 
+;;;###autoload
+(defun add-display-text-property (start end prop value
+                                        &optional object)
+  "Add display property PROP with VALUE to the text from START to END.
+If any text in the region has a non-nil `display' property, those
+properties are retained.
+
+If OBJECT is non-nil, it should be a string or a buffer.  If nil,
+this defaults to the current buffer."
+  (add-remove--display-text-property start end prop value object))
+
+;;;###autoload
+(defun remove-display-text-property (start end prop &optional object)
+  "Remove display property PROP from the text from START to END.
+If any text in the region has other `display' specs, those specs are
+retained.
+
+If OBJECT is non-nil, it should be a string or a buffer.  If nil,
+this defaults to the current buffer."
+  (add-remove--display-text-property start end prop nil object 'remove))
+
 ;;;###autoload
 (defun read-process-name (prompt)
   "Query the user for a process and return the process object."
diff --git a/lisp/visual-wrap.el b/lisp/visual-wrap.el
index ce397e6914f..b33e1acde82 100644
--- a/lisp/visual-wrap.el
+++ b/lisp/visual-wrap.el
@@ -145,34 +145,64 @@ visual-wrap--adjust-prefix
        (t
         "")))))
 
-(defun visual-wrap--apply-to-line (position)
-  "Apply visual-wrapping properties to the logical line starting at POSITION."
+(defun visual-wrap--remq-text-property (start end prop value)
+  "Remove a VALUE from the text property PROP from START to END.
+This removes the value from a text property containing a list, as with
+`remq' (which see)."
+  (save-excursion
+    (save-restriction
+      (narrow-to-region start end)
+      (goto-char (point-min))
+      (let (match)
+        (while (setq match (text-property-search-forward prop value #'memq))
+          (if-let* ((new-value (remq value (prop-match-value match))))
+              (put-text-property (prop-match-beginning match)
+                                 (prop-match-end match)
+                                 prop new-value)
+            (remove-text-properties (prop-match-beginning match)
+                                    (prop-match-end match)
+                                    (list prop nil))))))))
+
+(defun visual-wrap--apply-to-line (position &optional remove)
+  "Apply visual-wrapping properties to the logical line starting at POSITION.
+If REMOVE is non-nil, remove the visual-wrapping properties."
   (save-excursion
     (goto-char position)
     (when-let* ((first-line-prefix (fill-match-adaptive-prefix))
                 (next-line-prefix (visual-wrap--content-prefix
                                    first-line-prefix position)))
       (when (numberp next-line-prefix)
-        ;; Set a minimum width for the prefix so it lines up correctly
-        ;; with subsequent lines.  Make sure not to do this past the end
-        ;; of the line though!  (`fill-match-adaptive-prefix' could
-        ;; potentially return a prefix longer than the current line in
-        ;; the buffer.)
-        (add-display-text-property
-         position (min (+ position (length first-line-prefix))
-                       (pos-eol))
-         'min-width `((,next-line-prefix . width))))
+        (let ((display-end (min (+ position (length first-line-prefix))
+                                (pos-eol))))
+          (if (not remove)
+              ;; Set a minimum width for the prefix so it lines up
+              ;; correctly with subsequent lines.  Make sure not to do
+              ;; this past the end of the line though!
+              ;; (`fill-match-adaptive-prefix' could potentially return
+              ;; a prefix longer than the current line in the buffer.)
+              (add-display-text-property
+               position display-end
+               'min-width `((,next-line-prefix . width)))
+            ;; Clear the `min-width' display property.
+            (remove-display-text-property position display-end 'min-width))))
       (setq next-line-prefix (visual-wrap--adjust-prefix next-line-prefix))
-      (put-text-property
-       position (pos-eol) 'wrap-prefix
-       (if (numberp next-line-prefix)
-           `(space :align-to (,next-line-prefix . width))
-         next-line-prefix))
-      ;; Make sure that when typing at the end of a line with
-      ;; `wrap-prefix' set, we don't continue that property over
-      ;; subsequent lines.  See bug#76018.
-      (append-text-property position (pos-eol)
-                            'rear-nonsticky '(wrap-prefix)))))
+      (if (not remove)
+          ;; Apply `wrap-prefix' properties.
+          (progn
+            (put-text-property
+             position (pos-eol) 'wrap-prefix
+             (if (numberp next-line-prefix)
+                 `(space :align-to (,next-line-prefix . width))
+               next-line-prefix))
+            ;; Make sure that when typing at the end of a line with
+            ;; `wrap-prefix' set, we don't continue that property over
+            ;; subsequent lines.  See bug#76018.
+            (append-text-property position (pos-eol)
+                                  'rear-nonsticky '(wrap-prefix)))
+        ;; Remove `wrap-prefix' properties.
+        (remove-text-properties position (pos-eol) '(wrap-prefix nil))
+        (visual-wrap--remq-text-property position (pos-eol)
+                                         'rear-nonsticky 'wrap-prefix)))))
 
 (defun visual-wrap--content-prefix (prefix position)
   "Get the next-line prefix for the specified first-line PREFIX.
@@ -233,8 +263,9 @@ visual-wrap-fill-context-prefix
         (propertize prefix 'face face)
       prefix)))
 
-(defun visual-wrap-prefix-function (beg end)
-  "Indent the region between BEG and END with visual filling."
+(defun visual-wrap-prefix-function (beg end &optional remove)
+  "Indent the region between BEG and END with visual filling.
+If REMOVE is non-nil, remove the visual filling between BEG and END."
   ;; Any change at the beginning of a line might change its wrap
   ;; prefix, which affects the whole line.  So we need to "round-up"
   ;; `end' to the nearest end of line.  We do the same with `beg'
@@ -252,7 +283,7 @@ visual-wrap-prefix-function
         ;; If so, we can apply our visual wrapping properties to this
         ;; line and continue to the next line.
         (progn
-          (visual-wrap--apply-to-line (point))
+          (visual-wrap--apply-to-line (point) remove)
           (forward-line))
       ;; Otherwise, skip ahead until the end of any unsafe display
       ;; properties.  NOTE: We do this out of an abundance of caution to
@@ -290,7 +321,7 @@ visual-wrap-prefix-mode
     (with-silent-modifications
       (save-restriction
         (widen)
-        (remove-text-properties (point-min) (point-max) '(wrap-prefix nil))))))
+        (visual-wrap-prefix-function (point-min) (point-max) t)))))
 
 ;;;###autoload
 (define-globalized-minor-mode global-visual-wrap-prefix-mode
diff --git a/test/lisp/visual-wrap-tests.el b/test/lisp/visual-wrap-tests.el
index d3faa128637..b93e3e67d84 100644
--- a/test/lisp/visual-wrap-tests.el
+++ b/test/lisp/visual-wrap-tests.el
@@ -149,4 +149,19 @@ visual-wrap-tests/wrap-prefix-stickiness
                2 25 ( wrap-prefix (space :align-to (2 . width))
                       rear-nonsticky (wrap-prefix)))))))
 
+(ert-deftest visual-wrap-tests/cleanup ()
+  "Test that deactivating `visual-wrap-prefix-mode' cleans up text properties."
+  (with-temp-buffer
+    (insert "* hello\n* hi")
+    (visual-wrap-prefix-function (point-min) (point-max))
+    ;; Make sure we've added the visual-wrapping properties.
+    (should (equal (text-properties-at (point-min))
+                   '( rear-nonsticky (wrap-prefix)
+                      wrap-prefix (space :align-to (2 . width))
+                      display (min-width ((2 . width))))))
+    (visual-wrap-prefix-mode -1)
+    (should (equal-including-properties
+             (buffer-string)
+             "* hello\n* hi"))))
+
 ;; visual-wrap-tests.el ends here
-- 
2.25.1