fix: editor-state integrity — saving, stickiness, modified flag, undo
Justification is a re-layout of the same logical text; it now behaves that way toward the rest of the editor: - saving a justified buffer writes the LOGICAL text: before-save-hook unjustifies (spans remembered as markers), after-save-hook restores the justified view; the deterministic pair stays off undo history. Previously soft newlines and break hyphens were silently persisted to disk and the original whitespace was unrecoverable after reopen. - renderer marker properties (ekp-glue/soft-break/soft-hyphen/hidden/ justified) are registered in text-property-default-nonsticky: text typed after a glue no longer inherits the marker and is no longer deleted as a synthesized space by the next re-flow. - pure re-layout preserves buffer-modified-p when it was nil: no more lock files, auto-saves and "buffer modified" prompts from merely enabling ekp-auto-justify-mode. - changes applied by undo are not re-dirtied (no timer war against the user's undo sequence). - switching major modes tears justified state down cleanly (change-major-mode-hook), widening first so narrowing never leaves justified orphans. 5 new ERT tests (72 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9dcaeb0ba3
commit
ede518f10f
107
ekp-region.el
107
ekp-region.el
@ -27,7 +27,7 @@
|
|||||||
;; - `ekp-justify-region' / `ekp-unjustify-region': justify buffer text
|
;; - `ekp-justify-region' / `ekp-unjustify-region': justify buffer text
|
||||||
;; in place. Unjustification is a pure structural transform driven by
|
;; in place. Unjustification is a pure structural transform driven by
|
||||||
;; the text properties the renderer leaves behind (`ekp-glue',
|
;; the text properties the renderer leaves behind (`ekp-glue',
|
||||||
;; `ekp-soft-break', `ekp-soft-hyphen', `ekp-soft-trail'), so the
|
;; `ekp-soft-break', `ekp-soft-hyphen', `ekp-hidden'), so the
|
||||||
;; original text — including whitespace runs stripped at line breaks —
|
;; original text — including whitespace runs stripped at line breaks —
|
||||||
;; is recovered exactly, even after the justified text was edited.
|
;; is recovered exactly, even after the justified text was edited.
|
||||||
;;
|
;;
|
||||||
@ -102,6 +102,22 @@ they suffice.")
|
|||||||
(defvar ekp-region--inhibit nil
|
(defvar ekp-region--inhibit nil
|
||||||
"Non-nil while ekp-region is modifying the buffer itself.")
|
"Non-nil while ekp-region is modifying the buffer itself.")
|
||||||
|
|
||||||
|
(defvar-local ekp-region--save-state nil
|
||||||
|
"Spans unjustified for saving: list of (BEG-MARKER END-MARKER WIDTH).
|
||||||
|
Set by `ekp-region--before-save', consumed by `ekp-region--after-save'.")
|
||||||
|
|
||||||
|
(defmacro ekp-region--preserving-modified (&rest body)
|
||||||
|
"Run BODY, keeping the buffer unmodified if it was unmodified.
|
||||||
|
Justification is a reversible re-layout of the same logical text, so
|
||||||
|
it must not flip `buffer-modified-p' on its own — that would create
|
||||||
|
lock files, trigger auto-saves and \"buffer modified\" prompts for
|
||||||
|
buffers the user never edited."
|
||||||
|
(declare (indent 0) (debug t))
|
||||||
|
`(let ((ekp-region--modified-was (buffer-modified-p)))
|
||||||
|
(prog1 (progn ,@body)
|
||||||
|
(unless ekp-region--modified-was
|
||||||
|
(restore-buffer-modified-p nil)))))
|
||||||
|
|
||||||
(defvar-local ekp-region--auto-width nil
|
(defvar-local ekp-region--auto-width nil
|
||||||
"Pixel width the buffer is currently auto-justified to.")
|
"Pixel width the buffer is currently auto-justified to.")
|
||||||
|
|
||||||
@ -231,6 +247,7 @@ idempotent and can re-flow to a new width."
|
|||||||
(ekp-region--inhibit t)
|
(ekp-region--inhibit t)
|
||||||
(inhibit-read-only t))
|
(inhibit-read-only t))
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
|
(ekp-region--preserving-modified
|
||||||
(atomic-change-group
|
(atomic-change-group
|
||||||
;; Re-flow support: strip previous justification first.
|
;; Re-flow support: strip previous justification first.
|
||||||
(when (text-property-not-all beg end 'ekp-justified nil)
|
(when (text-property-not-all beg end 'ekp-justified nil)
|
||||||
@ -247,6 +264,9 @@ idempotent and can re-flow to a new width."
|
|||||||
(goto-char (+ beg (ekp-region--pos-for-offset
|
(goto-char (+ beg (ekp-region--pos-for-offset
|
||||||
justified point-offset)))))
|
justified point-offset)))))
|
||||||
(add-text-properties beg end (list 'ekp-justified pixel))))
|
(add-text-properties beg end (list 'ekp-justified pixel))))
|
||||||
|
;; Saving a justified buffer must write the logical text.
|
||||||
|
(add-hook 'before-save-hook #'ekp-region--before-save nil t)
|
||||||
|
(add-hook 'after-save-hook #'ekp-region--after-save nil t))
|
||||||
(set-marker beg nil)
|
(set-marker beg nil)
|
||||||
(set-marker end nil))))
|
(set-marker end nil))))
|
||||||
|
|
||||||
@ -261,6 +281,7 @@ tails. Text the user typed into the justified region is preserved."
|
|||||||
(ekp-region--inhibit t)
|
(ekp-region--inhibit t)
|
||||||
(inhibit-read-only t))
|
(inhibit-read-only t))
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
|
(ekp-region--preserving-modified
|
||||||
(save-excursion
|
(save-excursion
|
||||||
(goto-char (min beg end))
|
(goto-char (min beg end))
|
||||||
(while (< (point) end-m)
|
(while (< (point) end-m)
|
||||||
@ -288,9 +309,56 @@ tails. Text the user typed into the justified region is preserved."
|
|||||||
(next-property-change pos nil
|
(next-property-change pos nil
|
||||||
(marker-position
|
(marker-position
|
||||||
end-m))))))))
|
end-m))))))))
|
||||||
(remove-text-properties (min beg end) end-m '(ekp-justified nil)))
|
(remove-text-properties (min beg end) end-m '(ekp-justified nil))))
|
||||||
(set-marker end-m nil))))
|
(set-marker end-m nil))))
|
||||||
|
|
||||||
|
;;;; Saving: the file always receives the logical text
|
||||||
|
|
||||||
|
(defun ekp-region--justified-spans ()
|
||||||
|
"Return justified spans of the buffer as a list of (BEG END WIDTH).
|
||||||
|
BEG/END are positions; WIDTH is the span's `ekp-justified' value."
|
||||||
|
(let ((pos (point-min)) spans)
|
||||||
|
(while (< pos (point-max))
|
||||||
|
(let ((w (get-text-property pos 'ekp-justified))
|
||||||
|
(next (next-single-property-change pos 'ekp-justified
|
||||||
|
nil (point-max))))
|
||||||
|
(when w (push (list pos next w) spans))
|
||||||
|
(setq pos next)))
|
||||||
|
(nreverse spans)))
|
||||||
|
|
||||||
|
(defun ekp-region--before-save ()
|
||||||
|
"Restore the logical text before the buffer is written to disk.
|
||||||
|
Saving a justified buffer must never persist soft line breaks, glue
|
||||||
|
spaces or break hyphens: they are layout, not content. The spans are
|
||||||
|
remembered (as markers) and re-justified by `ekp-region--after-save',
|
||||||
|
so the user never sees the buffer un-justified."
|
||||||
|
(let ((spans (ekp-region--justified-spans)))
|
||||||
|
(when spans
|
||||||
|
;; The unjustify+rejustify pair is deterministic and cancels out
|
||||||
|
;; exactly, so keep it off the undo history.
|
||||||
|
(let ((buffer-undo-list t))
|
||||||
|
;; Marker-ize every span before the first unjustification
|
||||||
|
;; shifts the positions of the spans after it.
|
||||||
|
(setq ekp-region--save-state
|
||||||
|
(mapcar (pcase-lambda (`(,beg ,end ,width))
|
||||||
|
(list (copy-marker beg) (copy-marker end t) width))
|
||||||
|
spans))
|
||||||
|
(pcase-dolist (`(,beg ,end ,_width) ekp-region--save-state)
|
||||||
|
(ekp-unjustify-region beg end))))))
|
||||||
|
|
||||||
|
(defun ekp-region--after-save ()
|
||||||
|
"Re-justify the spans un-done by `ekp-region--before-save'."
|
||||||
|
(when ekp-region--save-state
|
||||||
|
(let ((buffer-undo-list t))
|
||||||
|
(pcase-dolist (`(,beg ,end ,width) ekp-region--save-state)
|
||||||
|
(when (and (marker-position beg) (marker-position end))
|
||||||
|
(ekp-justify-region beg end width))
|
||||||
|
(set-marker beg nil)
|
||||||
|
(set-marker end nil)))
|
||||||
|
(setq ekp-region--save-state nil)
|
||||||
|
;; The file on disk holds exactly this buffer's logical text.
|
||||||
|
(set-buffer-modified-p nil)))
|
||||||
|
|
||||||
;;;###autoload
|
;;;###autoload
|
||||||
(defun ekp-no-break-region (beg end)
|
(defun ekp-no-break-region (beg end)
|
||||||
"Mark the region as an unbreakable typesetting atom.
|
"Mark the region as an unbreakable typesetting atom.
|
||||||
@ -355,8 +423,12 @@ unbreakable span inside prose, use `ekp-no-break-region' instead."
|
|||||||
(nreverse merged)))
|
(nreverse merged)))
|
||||||
|
|
||||||
(defun ekp-region--after-change (beg end _len)
|
(defun ekp-region--after-change (beg end _len)
|
||||||
"Record the edit between BEG and END for incremental re-justification."
|
"Record the edit between BEG and END for incremental re-justification.
|
||||||
(when (and ekp-auto-justify-mode (not ekp-region--inhibit))
|
Changes applied by undo are not re-flowed: re-justifying behind the
|
||||||
|
user's back would fight the undo sequence (and immediately dirty what
|
||||||
|
undo just restored). The next real edit or resize re-flows normally."
|
||||||
|
(when (and ekp-auto-justify-mode (not ekp-region--inhibit)
|
||||||
|
(not undo-in-progress))
|
||||||
(push (cons (copy-marker beg) (copy-marker end)) ekp-region--dirty)
|
(push (cons (copy-marker beg) (copy-marker end)) ekp-region--dirty)
|
||||||
(when (timerp ekp-region--edit-timer)
|
(when (timerp ekp-region--edit-timer)
|
||||||
(cancel-timer ekp-region--edit-timer))
|
(cancel-timer ekp-region--edit-timer))
|
||||||
@ -513,18 +585,43 @@ the buffer text is restored exactly when the mode is turned off."
|
|||||||
(ekp-region--window-pixel (get-buffer-window)))
|
(ekp-region--window-pixel (get-buffer-window)))
|
||||||
(ekp-region--reflow (current-buffer) ekp-region--auto-width)
|
(ekp-region--reflow (current-buffer) ekp-region--auto-width)
|
||||||
(add-hook 'window-size-change-functions #'ekp-region--on-resize nil t)
|
(add-hook 'window-size-change-functions #'ekp-region--on-resize nil t)
|
||||||
(add-hook 'after-change-functions #'ekp-region--after-change nil t))
|
(add-hook 'after-change-functions #'ekp-region--after-change nil t)
|
||||||
|
(add-hook 'before-save-hook #'ekp-region--before-save nil t)
|
||||||
|
(add-hook 'after-save-hook #'ekp-region--after-save nil t)
|
||||||
|
;; Turning the major mode off/over kills local hooks silently;
|
||||||
|
;; the buffer must get its logical text back first.
|
||||||
|
(add-hook 'change-major-mode-hook #'ekp-region--teardown nil t))
|
||||||
(remove-hook 'window-size-change-functions #'ekp-region--on-resize t)
|
(remove-hook 'window-size-change-functions #'ekp-region--on-resize t)
|
||||||
(remove-hook 'after-change-functions #'ekp-region--after-change t)
|
(remove-hook 'after-change-functions #'ekp-region--after-change t)
|
||||||
|
(remove-hook 'change-major-mode-hook #'ekp-region--teardown t)
|
||||||
|
(ekp-region--teardown)
|
||||||
|
;; Keep the save hooks only while justified text remains (the
|
||||||
|
;; teardown above removed all of it; a later ekp-justify-region
|
||||||
|
;; re-adds them).
|
||||||
|
(remove-hook 'before-save-hook #'ekp-region--before-save t)
|
||||||
|
(remove-hook 'after-save-hook #'ekp-region--after-save t)))
|
||||||
|
|
||||||
|
(defun ekp-region--teardown ()
|
||||||
|
"Cancel timers and restore the whole buffer's logical text.
|
||||||
|
Runs when `ekp-auto-justify-mode' is turned off and, via
|
||||||
|
`change-major-mode-hook', when a major-mode switch is about to
|
||||||
|
discard the mode silently."
|
||||||
(when (timerp ekp-region--resize-timer)
|
(when (timerp ekp-region--resize-timer)
|
||||||
(cancel-timer ekp-region--resize-timer))
|
(cancel-timer ekp-region--resize-timer))
|
||||||
(when (timerp ekp-region--edit-timer)
|
(when (timerp ekp-region--edit-timer)
|
||||||
(cancel-timer ekp-region--edit-timer))
|
(cancel-timer ekp-region--edit-timer))
|
||||||
(ekp-region--cancel-pending)
|
(ekp-region--cancel-pending)
|
||||||
|
(dolist (p ekp-region--dirty)
|
||||||
|
(set-marker (car p) nil)
|
||||||
|
(set-marker (cdr p) nil))
|
||||||
(setq ekp-region--resize-timer nil
|
(setq ekp-region--resize-timer nil
|
||||||
ekp-region--edit-timer nil
|
ekp-region--edit-timer nil
|
||||||
ekp-region--dirty nil
|
ekp-region--dirty nil
|
||||||
ekp-region--auto-width nil)
|
ekp-region--auto-width nil)
|
||||||
|
;; Narrowing must not leave justified orphans outside the visible
|
||||||
|
;; region.
|
||||||
|
(save-restriction
|
||||||
|
(widen)
|
||||||
(ekp-unjustify-region (point-min) (point-max))))
|
(ekp-unjustify-region (point-min) (point-max))))
|
||||||
|
|
||||||
(provide 'ekp-region)
|
(provide 'ekp-region)
|
||||||
|
|||||||
7
ekp.el
7
ekp.el
@ -1910,6 +1910,13 @@ leftmost scan aligns them unambiguously."
|
|||||||
(setq i (1+ i))))
|
(setq i (1+ i))))
|
||||||
offsets))
|
offsets))
|
||||||
|
|
||||||
|
;; Text typed next to a marker character must never inherit the
|
||||||
|
;; marker: a self-inserted char inheriting `ekp-glue' would be treated
|
||||||
|
;; as a synthesized space by the next unjustification and deleted.
|
||||||
|
(dolist (prop '(ekp-glue ekp-soft-break ekp-soft-hyphen ekp-hidden
|
||||||
|
ekp-justified))
|
||||||
|
(setf (alist-get prop text-property-default-nonsticky) t))
|
||||||
|
|
||||||
(defun ekp--hide-string (string)
|
(defun ekp--hide-string (string)
|
||||||
"Return STRING marked `ekp-hidden' and displayed as nothing."
|
"Return STRING marked `ekp-hidden' and displayed as nothing."
|
||||||
(if (string-empty-p string)
|
(if (string-empty-p string)
|
||||||
|
|||||||
@ -285,6 +285,111 @@ the displaying WINDOW, with an arbitrary buffer current."
|
|||||||
(ekp-justify-region (point-min) (point-max) 50)
|
(ekp-justify-region (point-min) (point-max) 50)
|
||||||
(should (equal-including-properties (buffer-string) lazy)))))))
|
(should (equal-including-properties (buffer-string) lazy)))))))
|
||||||
|
|
||||||
|
;;;; Editor-state integrity (save / modified / undo / stickiness)
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-typed-char-inherits-no-marker ()
|
||||||
|
"Text typed right after a glue must not inherit renderer markers.
|
||||||
|
Regression: `self-insert-command' uses insert-and-inherit; a char
|
||||||
|
inheriting `ekp-glue' was deleted as a synthesized space by the next
|
||||||
|
unjustification."
|
||||||
|
(ekp-region-test--with-text "aaa bbb 中文 ccc"
|
||||||
|
(ekp-justify-region (point-min) (point-max) 200)
|
||||||
|
(let ((glue-pos (text-property-not-all (point-min) (point-max)
|
||||||
|
'ekp-glue nil)))
|
||||||
|
(should glue-pos)
|
||||||
|
(goto-char (1+ glue-pos))
|
||||||
|
(insert-and-inherit "X")
|
||||||
|
(let ((x (1+ glue-pos)))
|
||||||
|
(should-not (get-text-property x 'ekp-glue))
|
||||||
|
(should-not (get-text-property x 'display))
|
||||||
|
(should-not (get-text-property x 'ekp-soft-break))))
|
||||||
|
(ekp-unjustify-region (point-min) (point-max))
|
||||||
|
(should (= 1 (cl-count ?X (buffer-string))))))
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-save-writes-logical-text ()
|
||||||
|
"Saving a justified file buffer writes the logical text to disk,
|
||||||
|
keeps the buffer justified, and leaves it unmodified."
|
||||||
|
(let* ((file (make-temp-file "ekp-save-test"))
|
||||||
|
(text "中文保存测试内容足够长会断行的样子,再加一句凑长度。")
|
||||||
|
(make-backup-files nil)
|
||||||
|
(create-lockfiles nil))
|
||||||
|
(unwind-protect
|
||||||
|
(with-current-buffer (find-file-noselect file)
|
||||||
|
(insert text)
|
||||||
|
(ekp-justify-region (point-min) (point-max) 20)
|
||||||
|
(should (> (cl-count ?\n (buffer-string)) 0))
|
||||||
|
(save-buffer)
|
||||||
|
;; Disk: logical text only, no layout newlines.
|
||||||
|
(should (equal (with-temp-buffer
|
||||||
|
(insert-file-contents file)
|
||||||
|
(buffer-string))
|
||||||
|
text))
|
||||||
|
;; Buffer: still justified, and not "modified" vs its file.
|
||||||
|
(should (get-text-property (point-min) 'ekp-justified))
|
||||||
|
(should-not (buffer-modified-p))
|
||||||
|
;; And a second save still works (state was reset).
|
||||||
|
(insert "x")
|
||||||
|
(goto-char (point-min))
|
||||||
|
(save-buffer)
|
||||||
|
(should (equal (with-temp-buffer
|
||||||
|
(insert-file-contents file)
|
||||||
|
(buffer-string))
|
||||||
|
(concat text "x")))
|
||||||
|
(let ((kill-buffer-query-functions nil))
|
||||||
|
(kill-buffer)))
|
||||||
|
(delete-file file))))
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-justify-preserves-unmodified ()
|
||||||
|
"Pure re-layout must not flip `buffer-modified-p'."
|
||||||
|
(let* ((file (make-temp-file "ekp-mod-test"))
|
||||||
|
(make-backup-files nil)
|
||||||
|
(create-lockfiles nil))
|
||||||
|
(unwind-protect
|
||||||
|
(with-current-buffer (find-file-noselect file)
|
||||||
|
(insert "modified 标志保持检查内容足够长断行")
|
||||||
|
(save-buffer)
|
||||||
|
(should-not (buffer-modified-p))
|
||||||
|
(ekp-justify-region (point-min) (point-max) 30)
|
||||||
|
(should-not (buffer-modified-p))
|
||||||
|
(ekp-unjustify-region (point-min) (point-max))
|
||||||
|
(should-not (buffer-modified-p))
|
||||||
|
;; A real edit still marks the buffer modified.
|
||||||
|
(insert "y")
|
||||||
|
(should (buffer-modified-p))
|
||||||
|
(let ((kill-buffer-query-functions nil))
|
||||||
|
(set-buffer-modified-p nil)
|
||||||
|
(kill-buffer)))
|
||||||
|
(delete-file file))))
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-undo-changes-not-redirtied ()
|
||||||
|
"Changes applied by undo must not schedule a re-flow."
|
||||||
|
(ekp-region-test--with-mode "undo guard 检查内容 aaa bbb ccc" 100
|
||||||
|
(setq ekp-region--dirty nil)
|
||||||
|
(let ((undo-in-progress t))
|
||||||
|
(ekp-region--after-change (point-min) (1+ (point-min)) 0))
|
||||||
|
(should-not ekp-region--dirty)
|
||||||
|
(let ((undo-in-progress nil))
|
||||||
|
(ekp-region--after-change (point-min) (1+ (point-min)) 0))
|
||||||
|
(should ekp-region--dirty)
|
||||||
|
(dolist (p ekp-region--dirty)
|
||||||
|
(set-marker (car p) nil)
|
||||||
|
(set-marker (cdr p) nil))
|
||||||
|
(setq ekp-region--dirty nil)
|
||||||
|
(when (timerp ekp-region--edit-timer)
|
||||||
|
(cancel-timer ekp-region--edit-timer))))
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-major-mode-change-restores ()
|
||||||
|
"Switching major mode tears the justified state down cleanly."
|
||||||
|
(let ((text "major mode 切换检查 aaa bbb ccc ddd"))
|
||||||
|
(ekp-region-test--with-text text
|
||||||
|
(cl-letf (((symbol-function 'ekp-region--window-pixel)
|
||||||
|
(lambda (&optional _) 80)))
|
||||||
|
(ekp-auto-justify-mode 1)
|
||||||
|
(should (get-text-property (point-min) 'ekp-justified))
|
||||||
|
(fundamental-mode)
|
||||||
|
(should (equal (buffer-string) text))
|
||||||
|
(should-not ekp-auto-justify-mode)))))
|
||||||
|
|
||||||
(provide 'ekp-region-tests)
|
(provide 'ekp-region-tests)
|
||||||
|
|
||||||
;;; ekp-region-tests.el ends here
|
;;; ekp-region-tests.el ends here
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user