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

From: Jim Porter <[email-masked]>
Date: Tue, 27 May 2025 18:05:14 -0700

On 5/27/2025 1:23 PM, Stefan Monnier via Bug reports for GNU Emacs, the 
Swiss army knife of text editors wrote:
> So far, we generally don't try to interact properly with other packages
> using the same property.  It's usually good enough.
> In the present case, our minor mode tends to place a `wrap-prefix`
> property over pretty much the hole buffer anyway, so there's not much
> room for another package to use that property without conflict.

How about something like this? This should also address Juri and Eli's 
comments elsewhere in the bug.

One remaining bit that might be valuable is adding a few regression 
tests for 'append-text-property' and 'remove-display-text-property'.

From 3130fc624018ce0b3c663a9fba3045ac577bc645 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          | 24 +++++++++++++++++
 etc/NEWS                       |  6 +++++
 lisp/emacs-lisp/subr-x.el      | 14 ++++++++++
 lisp/eshell/em-prompt.el       | 17 ++----------
 lisp/visual-wrap.el            |  7 ++++-
 test/lisp/visual-wrap-tests.el | 48 ++++++++++++++++++++++++++++------
 6 files changed, 92 insertions(+), 24 deletions(-)

diff --git a/doc/lispref/text.texi b/doc/lispref/text.texi
index 75b2b1c3d60..3bb5d5ee087 100644
--- a/doc/lispref/text.texi
+++ b/doc/lispref/text.texi
@@ -3137,6 +3137,30 @@ Changing Properties
 @end example
 @end defun
 
+@defun append-text-property start end prop values &optional object
+This function @emph{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.
+
+For instance:
+
+@lisp
+@group
+(append-text-property 2 4 'rear-nonsticky '(read-only field))
+(append-text-property 3 5 'rear-nonsticky '(font-lock-face))
+@end group
+@end lisp
+
+After doing this, the region from 2 to 3 will have a
+@code{rear-nonsticky} property of @code{'(read-only field)}, the region
+from 3 to 4 will have a @code{rear-nonsticky} property of
+@code{'(read-only field font-lock-face)}, and finally the region from 4
+to 5 will have a @code{rear-nonsticky} property of
+@code{'(font-lock-face)}.
+
+@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..ef619c9f177 100644
--- a/lisp/visual-wrap.el
+++ b/lisp/visual-wrap.el
@@ -165,7 +165,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 6e6eab989f76ec8f0e86cf5c138b8a14f0eb65bd Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Tue, 27 May 2025 18:00:25 -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--delq-text-property): New function.
(visual-wrap--remove-properties): New function...
(visual-wrap-prefix-function, visual-wrap-prefix-mode): ... call it.

* 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      | 65 +++++++++++++++++++++++-----------
 lisp/visual-wrap.el            | 32 ++++++++++++++++-
 test/lisp/visual-wrap-tests.el | 15 ++++++++
 5 files changed, 128 insertions(+), 22 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..f2d4651b7f2 100644
--- a/lisp/emacs-lisp/subr-x.el
+++ b/lisp/emacs-lisp/subr-x.el
@@ -430,29 +430,25 @@ 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))
+        (let ((vector nil)
+              (changed nil))
           ;; Make disp into a list.
           (setq disp
                 (cond
@@ -465,14 +461,41 @@ add-display-text-property
                   disp)))
           ;; 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)))
+            (setq disp (delete old disp)
+                  changed t))
+          (unless remove
+            (setq disp (cons (list prop value) disp)
+                  changed t))
+          (when changed
+            (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 ef619c9f177..9e7f430ce73 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'.
 
@@ -231,6 +233,33 @@ visual-wrap-fill-context-prefix
         (propertize prefix 'face face)
       prefix)))
 
+(defun visual-wrap--delq-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
+`delq' (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--remove-properties (start end)
+  "Remove visual wrapping text properties from START to END."
+  ;; Remove `min-width' from any prefixes we detected.
+  (remove-display-text-property start end 'min-width)
+  ;; Remove `wrap-prefix' related properties from any lines with
+  ;; prefixes we detected.
+  (remove-text-properties start end '(wrap-prefix nil))
+  (visual-wrap--delq-text-property start end 'rear-nonsticky 'wrap-prefix))
+
 (defun visual-wrap-prefix-function (beg end)
   "Indent the region between BEG and END with visual filling."
   ;; Any change at the beginning of a line might change its wrap
@@ -243,6 +272,7 @@ visual-wrap-prefix-function
   (goto-char beg)
   (forward-line 0)
   (setq beg (point))
+  (visual-wrap--remove-properties beg end)
   (while (< (point) end)
     ;; Check if the display property at the end of this line is "safe".
     (if (visual-wrap--display-property-safe-p
@@ -288,7 +318,7 @@ visual-wrap-prefix-mode
     (with-silent-modifications
       (save-restriction
         (widen)
-        (remove-text-properties (point-min) (point-max) '(wrap-prefix nil))))))
+        (visual-wrap--remove-properties (point-min) (point-max))))))
 
 ;;;###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