diff --git a/tp-render-tests.el b/tp-render-tests.el index 8a95727..4c6e4c3 100644 --- a/tp-render-tests.el +++ b/tp-render-tests.el @@ -41,6 +41,7 @@ (defvar tp-rt-r2-text nil) (defvar tp-rt-r2m-text nil) (defvar tp-rt-r2n-text nil) +(defvar tp-rt-r2s-text nil) (defvar tp-rt-r3a-color nil) (defvar tp-rt-r3b-color nil) (defvar tp-rt-r3c-color nil) @@ -564,6 +565,84 @@ (set-marker m-prefix nil) (set-marker m-suffix nil))))) +;;; TXT-1: the suffix-boundary marker must track its character + +(defun tp-rt--txt1-marker-after-edit (old new marker-offset) + "Run a minimal-diff replacement of OLD by NEW with a boundary marker. +Insert \"HEAD \" OLD \" TAIL\" in a temp buffer, tag OLD with a +tp-name, put an insertion-type-nil marker at OLD's start plus +MARKER-OFFSET, replace via `tp--replace-reactive-text-in-buffer' and +return (MARKER-POSITION CHAR-AT-MARKER ORIGINAL-CHAR)." + (with-temp-buffer + (insert "HEAD ") + (let ((m-start (point))) + (insert old " TAIL") + (put-text-property m-start (+ m-start (length old)) + 'tp-name 'tp-rt-txt1-layer) + (let* ((mpos (+ m-start marker-offset)) + (mchar (char-after mpos)) + (mk (copy-marker mpos))) + (tp--replace-reactive-text-in-buffer 'tp-rt-txt1-layer new nil) + (prog1 (list (marker-position mk) (char-after mk) mchar) + (set-marker mk nil)))))) + +(ert-deftest tp-render-test-minimal-diff-suffix-start-marker-tracks () + "A marker on the FIRST character of the preserved suffix tracks it. +TXT-1: delete-then-insert collapsed such a marker onto the edit +start, stranding it before the inserted text; insert-then-delete +shifts it right with its character. Grow, same-length (the clearest +docstring violation) and shrink edits are all covered." + ;; Grow: "0" -> "42"; marker on the space before "items" (offset 8). + (pcase-let ((`(,pos ,got ,want) + (tp-rt--txt1-marker-after-edit + "count: 0 items" "count: 42 items" 8))) + (should (eq got want)) + (should (= pos 15))) ; 14 shifted right by 1 + ;; Same length: "0" -> "9"; the marker's correct position is + ;; numerically unchanged. + (pcase-let ((`(,pos ,got ,want) + (tp-rt--txt1-marker-after-edit + "count: 0 items" "count: 9 items" 8))) + (should (eq got want)) + (should (= pos 14))) + ;; Shrink: "42" -> "0". + (pcase-let ((`(,pos ,got ,want) + (tp-rt--txt1-marker-after-edit + "count: 42 items" "count: 0 items" 9))) + (should (eq got want)) + (should (= pos 14)))) + +(ert-deftest tp-render-test-minimal-diff-deleted-char-marker-at-edit-end () + "A marker whose character was deleted ends at the END of the edit. +The documented side effect of inserting before deleting; previously +such markers collapsed to the edit start. Either way they stay +inside the replacement span." + ;; "100" -> "42": marker on the middle "0" (strictly inside the + ;; edited span) ends after the inserted "42". + (pcase-let ((`(,pos ,_got ,_want) + (tp-rt--txt1-marker-after-edit + "count: 100 items" "count: 42 items" 8))) + ;; Edit span starts at buffer position 13 ("100"), insert "42": + ;; the marker lands at the end of the inserted text. + (should (= pos 15)))) + +(ert-deftest tp-render-test-minimal-diff-suffix-marker-real-path () + "The suffix-start marker tracks through a real setq-driven update." + (tp-rt-with-cleanup (tp-rt-r2s-layer) (tp-rt-r2s-text) + (setq tp-rt-r2s-text "count: 0 items") + (define-tp tp-rt-r2s-layer () '(tp-text $tp-rt-r2s-text)) + (with-temp-buffer + (insert "count: 0 items") + (tp-set 1 15 'tp-rt-r2s-layer) + (let ((m (copy-marker 9))) ; the space before "items" + (setq tp-rt-r2s-text "count: 42 items") + (should (equal (buffer-substring-no-properties (point-min) + (point-max)) + "count: 42 items")) + (should (eq (char-after m) ?\s)) + (should (= (marker-position m) 10)) + (set-marker m nil))))) + (ert-deftest tp-render-test-minimal-diff-identical-update-is-noop () "An identical-text reactive replacement leaves the buffer unmodified." (tp-rt-with-cleanup (tp-rt-r2n-layer) (tp-rt-r2n-text) diff --git a/tp-render.el b/tp-render.el index e27dc86..a88d45a 100644 --- a/tp-render.el +++ b/tp-render.el @@ -346,23 +346,32 @@ it will be applied to the text before updating." (tp--tp-text-transform layer-name raw-text) raw-text))) (when (and new-text (stringp new-text)) - (save-excursion - (tp--replace-reactive-text-in-buffer - layer-name new-text props))))))))) + ;; No save-excursion here: the replace function + ;; owns point restoration (its clamping semantics + ;; would be overridden by save-excursion's own + ;; drifting marker). + (tp--replace-reactive-text-in-buffer + layer-name new-text props)))))))) (tp--map-layer-buffers layer-name where update-buffer))) (defun tp--edit-region-minimal-diff (m-start m-end plain-text skip-props) "Make [M-START, M-END) of the current buffer read PLAIN-TEXT. Only the differing span of the region is edited: the common prefix -and suffix of the old and new text are left untouched, so point and -markers sitting in unchanged text keep their positions (point inside -the edited span ends up at the start of the edit). Does nothing when -the region already reads PLAIN-TEXT, so an identical-text update does -not mark the buffer as modified. +and suffix of the old and new text are left untouched. The +replacement is inserted BEFORE the old span is deleted, so markers +sitting in unchanged text keep tracking their characters - including +a marker at the first character of the preserved suffix, which the +old delete-then-insert order collapsed onto the edit start (TXT-1). +Markers whose characters were deleted end up at the end of the edit. +Does nothing when the region already reads PLAIN-TEXT, so an +identical-text update does not mark the buffer as modified. Properties present at M-START whose keys the plist SKIP-PROPS does not contain are re-applied over the edited span (a nil SKIP-PROPS carries every existing property); the untouched prefix and suffix -keep their own properties as is." +keep their own properties as is. +Returns the cons (EDIT-START . EDIT-END) of the replaced span in +PRE-edit coordinates - the caller uses it to clamp a remembered +point that sat inside the edit - or nil when nothing was edited." (let ((old-text (buffer-substring-no-properties m-start m-end))) (unless (equal old-text plain-text) ;; Text content differs: trim the common prefix and suffix and @@ -384,11 +393,14 @@ keep their own properties as is." (edit-end (- m-end suffix)) (insert-text (substring plain-text prefix (- new-len suffix))) (existing-props (text-properties-at m-start))) - (when (< edit-start edit-end) - (delete-region edit-start edit-end)) - (when (> (length insert-text) 0) - (goto-char edit-start) - (insert insert-text)) + ;; Insert first, then delete the (shifted) old span: an + ;; insertion-type-nil marker at the start of the preserved + ;; suffix sits strictly after EDIT-START, so the insertion + ;; shifts it right with its character, and the deletion of + ;; the old span just before it shifts it back into place. + (goto-char edit-start) + (insert insert-text) + (delete-region (point) (+ (point) (- edit-end edit-start))) ;; Carry over existing properties whose keys SKIP-PROPS does ;; not name onto the newly inserted span; the untouched ;; prefix and suffix keep their own properties as is. @@ -396,7 +408,8 @@ keep their own properties as is." (cl-loop for (key val) on existing-props by #'cddr do (unless (plist-member skip-props key) (put-text-property edit-start mid-end key - val))))))))) + val)))) + (cons edit-start edit-end)))))) (defun tp--pos-holds-layer-in-storage-only-p (pos layer-name) "Return non-nil when POS holds LAYER-NAME only inside `tp-layers'. @@ -433,44 +446,72 @@ properties, never text), so the model value still replaces the text there, but the layer's props are not applied directly; instead its stored stack entry, including the refreshed `tp-text', is written through, so `tp-show-layer' or a reveal by a later stack operation -renders current values." - (goto-char (point-min)) - (let ((match (text-property-search-forward 'tp-name layer-name t)) - (plain-text (substring-no-properties new-text))) - ;; Pass 1: regions where the layer is the rendered top layer - ;; (direct `tp-name'). - (while match - (let* ((m-start (prop-match-beginning match)) - (m-end (prop-match-end match))) - (tp--edit-region-minimal-diff m-start m-end plain-text props) - ;; Apply the layer's props, merged per embedded interval of NEW-TEXT. - ;; Keys are replaced (not accumulated); unrelated keys are untouched. - (tp--apply-reactive-text-props new-text props m-start) - ;; Continue searching after the fully updated region: a preserved - ;; suffix still carries the layer's `tp-name', and restarting the - ;; search inside it would re-match this region. - (goto-char (+ m-start (length plain-text)))) - (setq match (text-property-search-forward 'tp-name layer-name t))) - ;; Pass 2: regions where the layer sits only inside stack storage. - ;; Replace their text too, carrying ALL existing properties (the - ;; visible top layer's render cache and the `tp-layers' storage) - ;; over the edited span; the hidden/buried layer's own props are - ;; not applied directly. - (let ((pos (point-min))) - (while (< pos (point-max)) - (if (tp--pos-holds-layer-in-storage-only-p pos layer-name) - (let ((region-end pos)) - (while (and (< region-end (point-max)) - (tp--pos-holds-layer-in-storage-only-p - region-end layer-name)) - (setq region-end (or (next-property-change region-end) - (point-max)))) - (tp--edit-region-minimal-diff pos region-end plain-text nil) - (setq pos (+ pos (length plain-text)))) - (setq pos (or (next-property-change pos) (point-max)))))) - ;; Write the updated props - including the refreshed `tp-text' - - ;; through to the layer's entries in stack storage (HID-1). - (tp--write-layer-through-stack-storage layer-name props))) +renders current values. +This function owns point restoration (callers must not wrap it in +`save-excursion', whose own marker would drift): point outside the +edits keeps tracking its character, and point inside an edited span +is clamped to the start of that edit." + (let ((plain-text (substring-no-properties new-text)) + ;; Remember where the user's point was; the marker tracks all + ;; edits, and edits that swallow point clamp it explicitly. + (orig-point (copy-marker (point)))) + (unwind-protect + (cl-flet ((edit-tracking-point (m-start m-end skip-props) + ;; Run the minimal-diff edit; when the remembered + ;; point sat inside the replaced span, clamp it to + ;; the start of the edit (the documented + ;; behavior). + (let* ((was (marker-position orig-point)) + (span (tp--edit-region-minimal-diff + m-start m-end plain-text skip-props))) + (when (and span + (>= was (car span)) + (< was (cdr span))) + (set-marker orig-point (car span)))))) + (goto-char (point-min)) + ;; Pass 1: regions where the layer is the rendered top layer + ;; (direct `tp-name'). + (let ((match (text-property-search-forward 'tp-name + layer-name t))) + (while match + (let* ((m-start (prop-match-beginning match)) + (m-end (prop-match-end match))) + (edit-tracking-point m-start m-end props) + ;; Apply the layer's props, merged per embedded interval + ;; of NEW-TEXT. Keys are replaced (not accumulated); + ;; unrelated keys are untouched. + (tp--apply-reactive-text-props new-text props m-start) + ;; Continue searching after the fully updated region: a + ;; preserved suffix still carries the layer's `tp-name', + ;; and restarting the search inside it would re-match + ;; this region. + (goto-char (+ m-start (length plain-text)))) + (setq match (text-property-search-forward 'tp-name + layer-name t)))) + ;; Pass 2: regions where the layer sits only inside stack + ;; storage. Replace their text too, carrying ALL existing + ;; properties (the visible top layer's render cache and the + ;; `tp-layers' storage) over the edited span; the + ;; hidden/buried layer's own props are not applied directly. + (let ((pos (point-min))) + (while (< pos (point-max)) + (if (tp--pos-holds-layer-in-storage-only-p pos layer-name) + (let ((region-end pos)) + (while (and (< region-end (point-max)) + (tp--pos-holds-layer-in-storage-only-p + region-end layer-name)) + (setq region-end (or (next-property-change + region-end) + (point-max)))) + (edit-tracking-point pos region-end nil) + (setq pos (+ pos (length plain-text)))) + (setq pos (or (next-property-change pos) (point-max)))))) + ;; Write the updated props - including the refreshed + ;; `tp-text' - through to the layer's entries in stack + ;; storage (HID-1). + (tp--write-layer-through-stack-storage layer-name props)) + (goto-char orig-point) + (set-marker orig-point nil)))) (defun tp--tp-text-replace (start end final-text result-props object preserve-props) "Replace [START, END) of OBJECT with FINAL-TEXT, handling props.