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 17:11:15 -0700

On 2/2/2025 9:50 AM, Kévin Le Gouguec wrote:
> Heya!
> 
> To reproduce from emacs -Q:
> 
>      C-x b repro
>      M-x visual-wrap-prefix-mode
>      - first line of [words…]
>      RET
>      RET
>      second line of [words…]
>      C-o
> 
> Expectation: continuation lines for the "second line of [words…]" should
> not have any indentation.
> Observation: text on that second line has these properties:
> 
>      There are text properties here:
>        fontified            t
>        wrap-prefix          (space :align-to (2 . width))
> 
> (NB: "C-o" in the recipe ensures there is a character at point,
> otherwise "C-u C-x =" shows nothing on account of being at EOB)
> 
> Denoting "hard spaces" (buffer text) with "·" and "visual spaces"
> (visual-wrap-prefix-mode decoration) with " ", text thus wraps like
> this:
> 
>      -·first·line·of·
>        words·words
> 
>      second·line·of
>        words·words
> 
> Not sure what the right fix is; having just learned about text property
> "stickiness", I can at least work around the issue with…
> 
>      (push '(wrap-prefix . t) text-property-default-nonsticky)
> 
> … but I have not dogfooded this much yet.

Attached is a patch with a test for this, relying on the 
'rear-nonsticky' text property.

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.

From aaef7465b48c78f880998f474e91297faeba8511 Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Mon, 26 May 2025 16:57:25 -0700
Subject: [PATCH] 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            |  7 ++++-
 test/lisp/visual-wrap-tests.el | 49 ++++++++++++++++++++++++++++------
 6 files changed, 76 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..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..2a2a16eb8d5 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,29 @@ 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