From a12a3c1191e74044bad0dd2c8950286f93bd90d5 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Mon, 27 Jul 2026 01:05:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20coexist=20with=20the=20Emacs=20ecosyste?= =?UTF-8?q?m=20=E2=80=94=20isearch,=20kill=20ring,=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isearch searches the logical text: entering isearch temporarily un-justifies (spans remembered as markers), exiting restores the layout. Justified CJK lines contain real injected spaces, which made even same-line phrases unfindable. - the kill ring receives the logical text via a buffer-local filter-buffer-substring-function: yanking justified text elsewhere transports words, not the source window's pixel layout. - multi-window: the layout width is now the narrowest window showing the buffer (was: whichever window resized last, leaving the other window overflowing), and window-configuration-change-hook catches the buffer becoming displayed, splits, and closed windows. - paragraphs containing field or read-only text (comint prompts) are never re-written; interactive justify/unjustify on read-only buffers signals instead of editing through inhibit-read-only. - edit re-flows wait for an active input-method composition (quail preedit) to finish instead of re-writing the text under it. 4 new ERT tests (76 total). Co-Authored-By: Claude Fable 5 --- ekp-region.el | 196 ++++++++++++++++++++++++++++++-------- tests/ekp-region-tests.el | 51 ++++++++++ 2 files changed, 207 insertions(+), 40 deletions(-) diff --git a/ekp-region.el b/ekp-region.el index c3b817b..518ebae 100644 --- a/ekp-region.el +++ b/ekp-region.el @@ -148,6 +148,16 @@ extend past the flush edge, so the layout width must leave room." ekp-region-margin-pixel (ekp-region-protrusion-reserve)))) +(defun ekp-region--effective-width (&optional buffer) + "Justification width for BUFFER: the narrowest window showing it. +With the buffer in several windows only one width can be laid out; +the narrowest keeps every window free of overflow-wrapped lines. +Falls back to the selected window when the buffer is not displayed." + (let ((wins (get-buffer-window-list (or buffer (current-buffer)) nil t))) + (if wins + (apply #'min (mapcar #'ekp-region--window-pixel wins)) + (ekp-region--window-pixel)))) + ;;;; Pure string transforms (defun ekp-region--split-hard (string) @@ -181,6 +191,10 @@ Code blocks and other protected text: marked with the `ekp-verbatim' property, matching `ekp-region-skip-faces', or accepted by `ekp-region-skip-predicate'." (or (text-property-not-all 0 (length para) 'ekp-verbatim nil para) + ;; Structured buffer text (comint/eshell prompts, forms) must + ;; never be re-written: fields and read-only spans stay put. + (text-property-not-all 0 (length para) 'field nil para) + (text-property-not-all 0 (length para) 'read-only nil para) (and ekp-region-skip-faces (ekp-region--face-hit-p para)) (and ekp-region-skip-predicate (funcall ekp-region-skip-predicate para)))) @@ -239,8 +253,11 @@ interactively, a numeric prefix argument supplies it explicitly. Already-justified text is unjustified first, so the command is idempotent and can re-flow to a new width." (interactive - (list (region-beginning) (region-end) - (and current-prefix-arg (prefix-numeric-value current-prefix-arg)))) + (progn + (barf-if-buffer-read-only) + (list (region-beginning) (region-end) + (and current-prefix-arg + (prefix-numeric-value current-prefix-arg))))) (setq pixel (or pixel (ekp-region--window-pixel))) (let ((beg (copy-marker (min beg end))) (end (copy-marker (max beg end) t)) @@ -264,19 +281,40 @@ idempotent and can re-flow to a new width." (goto-char (+ beg (ekp-region--pos-for-offset justified point-offset))))) (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)) + (ekp-region--install-integrations)) (set-marker beg nil) (set-marker end nil)))) +(defun ekp-region--install-integrations () + "Install the buffer-local hooks justified text depends on. +Idempotent; added by `ekp-justify-region' and `ekp-auto-justify-mode'." + ;; 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) + ;; Isearch searches the logical text. + (add-hook 'isearch-mode-hook #'ekp-region--isearch-begin nil t) + (add-hook 'isearch-mode-end-hook #'ekp-region--isearch-end nil t) + ;; The kill ring receives the logical text. + (setq-local filter-buffer-substring-function + #'ekp-region--filter-buffer-substring)) + +(defun ekp-region--remove-integrations () + "Remove the hooks installed by `ekp-region--install-integrations'." + (remove-hook 'before-save-hook #'ekp-region--before-save t) + (remove-hook 'after-save-hook #'ekp-region--after-save t) + (remove-hook 'isearch-mode-hook #'ekp-region--isearch-begin t) + (remove-hook 'isearch-mode-end-hook #'ekp-region--isearch-end t) + (when (eq filter-buffer-substring-function + #'ekp-region--filter-buffer-substring) + (kill-local-variable 'filter-buffer-substring-function))) + ;;;###autoload (defun ekp-unjustify-region (beg end) "Restore the logical text between BEG and END. Removes synthesized glue and soft hyphens, replaces soft line breaks with the whitespace they swallowed, and re-exposes hidden paragraph tails. Text the user typed into the justified region is preserved." - (interactive "r") + (interactive "*r") (let ((end-m (copy-marker (max beg end) t)) (ekp-region--inhibit t) (inhibit-read-only t)) @@ -359,6 +397,61 @@ so the user never sees the buffer un-justified." ;; The file on disk holds exactly this buffer's logical text. (set-buffer-modified-p nil))) +;;;; Isearch: search the logical text + +(defvar-local ekp-region--isearch-state nil + "Spans unjustified while isearch is active: ((BEG-M END-M WIDTH)...).") + +(defun ekp-region--isearch-begin () + "Show the logical text while searching. +Justified layout injects real space characters between CJK glyphs and +splits words across soft breaks and hyphens, so searching the layout +finds almost nothing. The buffer is un-justified for the duration of +the search and restored by `ekp-region--isearch-end'." + (let ((spans (and (null ekp-region--isearch-state) + (ekp-region--justified-spans)))) + (when spans + (let ((buffer-undo-list t)) + (setq ekp-region--isearch-state + (mapcar (pcase-lambda (`(,beg ,end ,width)) + (list (copy-marker beg) (copy-marker end t) width)) + spans)) + (pcase-dolist (`(,beg ,end ,_w) ekp-region--isearch-state) + (ekp-unjustify-region beg end)))))) + +(defun ekp-region--isearch-end () + "Restore the justified layout after isearch." + (when ekp-region--isearch-state + (let ((buffer-undo-list t)) + (pcase-dolist (`(,beg ,end ,width) ekp-region--isearch-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--isearch-state nil))) + +;;;; Kill/yank: the kill ring receives the logical text + +(defun ekp-region--logical-string (string) + "Return STRING with any ekp layout markers structurally inverted. +Non-justified strings are returned unchanged (same object)." + (if (cl-some (lambda (prop) + (text-property-not-all 0 (length string) prop nil string)) + '(ekp-glue ekp-soft-break ekp-soft-hyphen + ekp-hidden ekp-justified)) + (with-temp-buffer + (insert string) + (ekp-unjustify-region (point-min) (point-max)) + (buffer-string)) + string)) + +(defun ekp-region--filter-buffer-substring (beg end &optional delete) + "Extract BEG..END for the kill ring as logical text. +Killing justified text and yanking it elsewhere must transport the +words, not the pixel layout of the source window (DELETE as in +`filter-buffer-substring-function')." + (ekp-region--logical-string (buffer-substring--filter beg end delete))) + ;;;###autoload (defun ekp-no-break-region (beg end) "Mark the region as an unbreakable typesetting atom. @@ -436,26 +529,39 @@ undo just restored). The next real edit or resize re-flows normally." (run-with-idle-timer ekp-auto-justify-edit-delay nil #'ekp-region--flush-dirty (current-buffer))))) +(defun ekp-region--composing-p () + "Non-nil while an input method composition (quail preedit) is active. +Re-flowing the buffer under a live preedit overlay corrupts the +composition the user is still typing." + (and (bound-and-true-p quail-overlay) + (overlayp quail-overlay) + (overlay-buffer quail-overlay))) + (defun ekp-region--flush-dirty (buffer) "Re-justify the paragraphs of BUFFER touched by recent edits." (when (buffer-live-p buffer) (with-current-buffer buffer - (when (and ekp-auto-justify-mode ekp-region--dirty ekp-region--auto-width) - (let* ((pairs (prog1 ekp-region--dirty (setq ekp-region--dirty nil))) - ;; Convert all bounds to markers before the first - ;; re-justification shifts later positions. - (regions (mapcar (lambda (r) - (cons (copy-marker (car r)) - (copy-marker (cdr r) t))) - (ekp-region--merge-regions - (mapcar #'ekp-region--para-bounds pairs))))) - (dolist (r regions) - (ekp-justify-region (car r) (cdr r) ekp-region--auto-width) - (set-marker (car r) nil) - (set-marker (cdr r) nil)) - (dolist (p pairs) - (set-marker (car p) nil) - (set-marker (cdr p) nil))))))) + (if (ekp-region--composing-p) + ;; Let the user finish composing; try again after the delay. + (setq ekp-region--edit-timer + (run-with-idle-timer ekp-auto-justify-edit-delay nil + #'ekp-region--flush-dirty buffer)) + (when (and ekp-auto-justify-mode ekp-region--dirty ekp-region--auto-width) + (let* ((pairs (prog1 ekp-region--dirty (setq ekp-region--dirty nil))) + ;; Convert all bounds to markers before the first + ;; re-justification shifts later positions. + (regions (mapcar (lambda (r) + (cons (copy-marker (car r)) + (copy-marker (cdr r) t))) + (ekp-region--merge-regions + (mapcar #'ekp-region--para-bounds pairs))))) + (dolist (r regions) + (ekp-justify-region (car r) (cdr r) ekp-region--auto-width) + (set-marker (car r) nil) + (set-marker (cdr r) nil)) + (dolist (p pairs) + (set-marker (car p) nil) + (set-marker (cdr p) nil)))))))) (defun ekp-region--on-resize (window-or-frame) "Debounced re-flow after WINDOW-OR-FRAME changed size. @@ -468,15 +574,25 @@ buffer current — so resolve both explicitly." (t (get-buffer-window (current-buffer)))))) (when (window-live-p win) (with-current-buffer (window-buffer win) - (when ekp-auto-justify-mode - (let ((w (ekp-region--window-pixel win))) - (when (and ekp-region--auto-width (/= w ekp-region--auto-width)) - (when (timerp ekp-region--resize-timer) - (cancel-timer ekp-region--resize-timer)) - (setq ekp-region--resize-timer - (run-with-timer ekp-auto-justify-resize-delay nil - #'ekp-region--reflow - (current-buffer) w))))))))) + (ekp-region--schedule-reflow))))) + +(defun ekp-region--on-window-change () + "Re-check the layout width after the window configuration changed. +Catches the buffer becoming displayed (possibly for the first time), +window splits, and deletions of the narrowest window." + (ekp-region--schedule-reflow)) + +(defun ekp-region--schedule-reflow () + "Debounce a re-flow of the current buffer to its effective width." + (when ekp-auto-justify-mode + (let ((w (ekp-region--effective-width))) + (when (and ekp-region--auto-width (/= w ekp-region--auto-width)) + (when (timerp ekp-region--resize-timer) + (cancel-timer ekp-region--resize-timer)) + (setq ekp-region--resize-timer + (run-with-timer ekp-auto-justify-resize-delay nil + #'ekp-region--reflow + (current-buffer) w)))))) (defun ekp-region--cancel-pending () "Drop any queued lazy re-flow chunks." @@ -581,25 +697,25 @@ the buffer text is restored exactly when the mode is turned off." :lighter " EKP" (if ekp-auto-justify-mode (progn - (setq ekp-region--auto-width - (ekp-region--window-pixel (get-buffer-window))) + (setq ekp-region--auto-width (ekp-region--effective-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-configuration-change-hook + #'ekp-region--on-window-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) + (ekp-region--install-integrations) ;; 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-configuration-change-hook + #'ekp-region--on-window-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))) + ;; The teardown removed all justified text; a later + ;; ekp-justify-region re-installs what it needs. + (ekp-region--remove-integrations))) (defun ekp-region--teardown () "Cancel timers and restore the whole buffer's logical text. diff --git a/tests/ekp-region-tests.el b/tests/ekp-region-tests.el index 4060daa..89986da 100644 --- a/tests/ekp-region-tests.el +++ b/tests/ekp-region-tests.el @@ -390,6 +390,57 @@ keeps the buffer justified, and leaves it unmodified." (should (equal (buffer-string) text)) (should-not ekp-auto-justify-mode))))) +;;;; Ecosystem compatibility (kill ring / isearch / fields / read-only) + +(ert-deftest ekp-region-test-kill-ring-gets-logical-text () + "Copying justified text extracts the logical text. +CJK justification injects real space characters between glyphs; +they must not travel with a kill/yank." + (let ((text "中文复制检查内容足够长会断行 with some latin")) + (ekp-region-test--with-text text + (ekp-justify-region (point-min) (point-max) 20) + (should (local-variable-p 'filter-buffer-substring-function)) + (should (equal (filter-buffer-substring (point-min) (point-max)) + text))))) + +(ert-deftest ekp-region-test-isearch-sees-logical-text () + "The isearch hooks expose the logical text, then restore the layout." + (let ((text "跨行搜索的目标短语必须能找到 internationalization word")) + (ekp-region-test--with-text text + (ekp-justify-region (point-min) (point-max) 20) + (let ((justified (buffer-string))) + ;; Sanity: layout breaks the phrase apart. + (should (> (cl-count ?\n justified) 0)) + (ekp-region--isearch-begin) + ;; Logical view: the full phrase and the long word are findable. + (goto-char (point-min)) + (should (search-forward "目标短语必须能找到" nil t)) + (goto-char (point-min)) + (should (search-forward "internationalization" nil t)) + (ekp-region--isearch-end) + ;; Layout restored byte-identically. + (should (equal-including-properties (buffer-string) justified)))))) + +(ert-deftest ekp-region-test-field-paragraph-skipped () + "Paragraphs containing field or read-only text stay verbatim." + (let* ((prompt (propertize "shell> " 'field 'output)) + (text (concat prompt "command output here\n" + "prose paragraph long enough to wrap around"))) + (ekp-region-test--with-text text + (ekp-justify-region (point-min) (point-max) 15) + (goto-char (point-min)) + (should (search-forward "command output here" nil t))))) + +(ert-deftest ekp-region-test-read-only-command-barfs () + "Interactive justify on a read-only buffer signals, not corrupts." + (ekp-region-test--with-text "read only 检查内容" + (set-mark (point-min)) + (goto-char (point-max)) + (activate-mark) + (read-only-mode 1) + (should-error (call-interactively #'ekp-justify-region) + :type 'buffer-read-only))) + (provide 'ekp-region-tests) ;;; ekp-region-tests.el ends here