From dc2f6da846a738fb259865343183e9698d5cb651 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sun, 26 Jul 2026 23:22:56 +0800 Subject: [PATCH] feat: visible-first lazy re-flow for large buffers; packaging polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buffers past ekp-auto-justify-lazy-threshold (20k chars) no longer re-justify wholesale on a width change: the visible span updates synchronously and the rest follows in polite background chunks (run-with-timer ticks that yield to pending input). Measured on a 300-paragraph, 132k-char article: perceived latency 332 ms → 15 ms, background completion ~0.5 s. Fixed in the process: adjacent chunk markers shared a boundary, and the earlier chunk's delete+insert pushed the next chunk's start marker back over the freshly inserted text — chunks grew linearly (2.2k, 4.9k, 7.4k… chars) and background work went quadratic (14 s). Start markers now use insertion-type t. Regression test asserts the chunked result equals the one-shot result property-for-property. Also: org/markdown skip-face presets (ekp-region-org-skip-faces / ekp-region-markdown-skip-faces), MELPA header hygiene (Version + URL on the main file, stale Package-Requires dropped from ekp-hyphen.el), readme notes. 67 ERT green; fuzz 300/300. Co-Authored-By: Claude Fable 5 --- ekp-hyphen.el | 1 - ekp-region.el | 125 ++++++++++++++++++++++++++++++++++++-- ekp.el | 2 + readme.md | 19 +++++- readme_zh.md | 19 +++++- tests/ekp-region-tests.el | 30 +++++++++ 6 files changed, 186 insertions(+), 10 deletions(-) diff --git a/ekp-hyphen.el b/ekp-hyphen.el index 98d91d7..d5547bf 100644 --- a/ekp-hyphen.el +++ b/ekp-hyphen.el @@ -3,7 +3,6 @@ ;; Copyright (C) 2024 ;; Author: emacs-kp contributors ;; Keywords: text, hyphenation, typesetting -;; Package-Requires: ((emacs "27.1")) ;;; Commentary: diff --git a/ekp-region.el b/ekp-region.el index a7653db..31ece22 100644 --- a/ekp-region.el +++ b/ekp-region.el @@ -46,6 +46,27 @@ by the display engine due to rounding." "Idle seconds before edited paragraphs are re-justified." :type 'number) +(defcustom ekp-auto-justify-lazy-threshold 20000 + "Buffer size (characters) beyond which re-flows go visible-first. +Below it a window-width change re-justifies the whole buffer at +once; above it the visible portion is done synchronously and the +rest follows in idle background chunks." + :type 'natnum) + +(defcustom ekp-auto-justify-chunk-size 10 + "Paragraphs re-justified per background tick in lazy re-flows." + :type 'natnum) + +(defconst ekp-region-org-skip-faces + '(org-block org-block-begin-line org-block-end-line org-code + org-verbatim org-table org-meta-line) + "Reasonable `ekp-region-skip-faces' preset for Org buffers.") + +(defconst ekp-region-markdown-skip-faces + '(markdown-code-face markdown-inline-code-face markdown-pre-face + markdown-table-face) + "Reasonable `ekp-region-skip-faces' preset for Markdown buffers.") + (defcustom ekp-region-skip-faces nil "Faces whose paragraphs are never justified (kept verbatim). Point major-mode faces here — e.g. `org-block' and `org-code' for @@ -72,6 +93,11 @@ they suffice.") (defvar-local ekp-region--dirty nil "Pending edited regions, as a list of (BEG-MARKER . END-MARKER).") +(defvar-local ekp-region--pending nil + "Lazy re-flow state: (WIDTH . CHUNKS), CHUNKS = ((BEG-M . END-M)...).") + +(defvar-local ekp-region--chunk-timer nil) + ;;;; Width (defun ekp-region-protrusion-reserve () @@ -282,8 +308,10 @@ unbreakable span inside prose, use `ekp-no-break-region' instead." (defun ekp-region--para-bounds (marker-pair) "Hard-paragraph bounds containing MARKER-PAIR, as (BEG . END)." - (let ((b (marker-position (car marker-pair))) - (e (marker-position (cdr marker-pair)))) + (let ((b (let ((x (car marker-pair))) + (if (markerp x) (marker-position x) x))) + (e (let ((x (cdr marker-pair))) + (if (markerp x) (marker-position x) x)))) (save-excursion (goto-char (max (point-min) (min b (point-max)))) (while (and (> (point) (point-min)) @@ -361,13 +389,99 @@ buffer current — so resolve both explicitly." #'ekp-region--reflow (current-buffer) w))))))))) +(defun ekp-region--cancel-pending () + "Drop any queued lazy re-flow chunks." + (when (timerp ekp-region--chunk-timer) + (cancel-timer ekp-region--chunk-timer)) + (setq ekp-region--chunk-timer nil) + (dolist (c (cdr ekp-region--pending)) + (set-marker (car c) nil) + (set-marker (cdr c) nil)) + (setq ekp-region--pending nil)) + +(defun ekp-region--make-chunks (beg end) + "Split [BEG, END) into marker-pair chunks of whole hard paragraphs." + (let ((chunks nil)) + (save-excursion + (goto-char beg) + (while (< (point) end) + (let ((cbeg (point)) (paras 0)) + (while (and (< (point) end) + (< paras ekp-auto-justify-chunk-size)) + (if (search-forward "\n" end 'move) + (unless (get-text-property (match-beginning 0) + 'ekp-soft-break) + (setq paras (1+ paras))) + nil)) + (when (> (point) cbeg) + ;; BEG has insertion-type t: the previous chunk's re-insert + ;; happens exactly at this boundary, and the marker must + ;; end up after that text, not before it. + (push (cons (copy-marker cbeg t) (copy-marker (point) t)) + chunks))))) + (nreverse chunks))) + +(defun ekp-region--visible-span () + "Visible portion of the current buffer, as (BEG . END)." + (let ((win (get-buffer-window (current-buffer)))) + (if win + (cons (window-start win) (or (window-end win t) (point-max))) + (cons (point-min) (point-max))))) + +(defun ekp-region--process-chunk (buffer) + "Re-justify the next queued chunk of BUFFER, then reschedule." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq ekp-region--chunk-timer nil) + (cond + ((or (not ekp-auto-justify-mode) (null ekp-region--pending)) + (ekp-region--cancel-pending)) + ;; a newer re-flow superseded this queue + ((not (eql (car ekp-region--pending) ekp-region--auto-width)) + (ekp-region--cancel-pending)) + ;; be polite: yield to pending input, try again shortly + ((input-pending-p) + (setq ekp-region--chunk-timer + (run-with-timer 0.1 nil #'ekp-region--process-chunk buffer))) + (t + (let* ((width (car ekp-region--pending)) + (chunk (pop (cdr ekp-region--pending)))) + (when chunk + (ekp-justify-region (car chunk) (cdr chunk) width) + (set-marker (car chunk) nil) + (set-marker (cdr chunk) nil)) + (if (cdr ekp-region--pending) + (setq ekp-region--chunk-timer + (run-with-timer 0.02 nil + #'ekp-region--process-chunk buffer)) + (setq ekp-region--pending nil)))))))) + (defun ekp-region--reflow (buffer width) - "Re-justify all of BUFFER to WIDTH." + "Re-justify BUFFER to WIDTH — whole buffer, or visible-first when large." (when (buffer-live-p buffer) (with-current-buffer buffer (when ekp-auto-justify-mode (setq ekp-region--auto-width width) - (ekp-justify-region (point-min) (point-max) width))))) + (ekp-region--cancel-pending) + (if (< (- (point-max) (point-min)) + ekp-auto-justify-lazy-threshold) + (ekp-justify-region (point-min) (point-max) width) + ;; visible part now, the rest in background chunks + (pcase-let* ((`(,vbeg . ,vend) (ekp-region--visible-span)) + (`(,pbeg . ,pend) + (ekp-region--para-bounds (cons vbeg vend)))) + (ekp-justify-region pbeg pend width) + (let ((chunks (nconc + ;; start at PEND so the hard newline there + ;; gets its ekp-justified property too + (ekp-region--make-chunks pend (point-max)) + (ekp-region--make-chunks (point-min) pbeg)))) + (when chunks + (setq ekp-region--pending (cons width chunks)) + (setq ekp-region--chunk-timer + (run-with-timer 0.02 nil + #'ekp-region--process-chunk + buffer)))))))))) ;;;###autoload (define-minor-mode ekp-auto-justify-mode @@ -380,7 +494,7 @@ the buffer text is restored exactly when the mode is turned off." (progn (setq ekp-region--auto-width (ekp-region--window-pixel (get-buffer-window))) - (ekp-justify-region (point-min) (point-max) 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 'after-change-functions #'ekp-region--after-change nil t)) (remove-hook 'window-size-change-functions #'ekp-region--on-resize t) @@ -389,6 +503,7 @@ the buffer text is restored exactly when the mode is turned off." (cancel-timer ekp-region--resize-timer)) (when (timerp ekp-region--edit-timer) (cancel-timer ekp-region--edit-timer)) + (ekp-region--cancel-pending) (setq ekp-region--resize-timer nil ekp-region--edit-timer nil ekp-region--dirty nil diff --git a/ekp.el b/ekp.el index 9a3c69d..729b4f5 100644 --- a/ekp.el +++ b/ekp.el @@ -2,6 +2,8 @@ ;; Copyright (C) 2024 ;; Author: emacs-kp contributors +;; Version: 1.0.0 +;; URL: https://github.com/Kinneyzhang/emacs-kp ;; Keywords: text, typesetting, CJK ;; Package-Requires: ((emacs "29.1")) diff --git a/readme.md b/readme.md index 17895e9..c7bc4b1 100644 --- a/readme.md +++ b/readme.md @@ -55,7 +55,7 @@ cd ekp_c && make # requires C11 compiler, produces ekp.dylib/.so/.dll ``` ```elisp -(ekp-c-module-load) ; prints "ekp-c module loaded (version 1.1, N threads)" +(ekp-c-module-load) ; prints "ekp-c module loaded (version 1.4, N threads)" ``` Once loaded (and since `ekp-use-c-module` defaults to `t`), all @@ -89,6 +89,21 @@ loading refuses with a message asking you to rebuild. `ekp-region-margin-pixel` (default 2) is subtracted from the window width as a rounding safety margin. +Large buffers (over `ekp-auto-justify-lazy-threshold` characters, +default 20 000) re-flow visible-first: the portion on screen updates +synchronously (~15 ms) and the rest follows in idle background chunks. + +Mode presets for verbatim protection: + +```elisp +(add-hook 'org-mode-hook + (lambda () + (setq-local ekp-region-skip-faces ekp-region-org-skip-faces))) +(add-hook 'markdown-mode-hook + (lambda () + (setq-local ekp-region-skip-faces ekp-region-markdown-skip-faces))) +``` + ### Protecting code and other verbatim text - Block level: paragraphs carrying the `ekp-verbatim` text property @@ -221,7 +236,7 @@ off most for optimal-width search and long multi-paragraph texts. ## Testing ```bash -tests/run-tests.sh /path/to/emacs # 36 ERT tests, all batch-safe +tests/run-tests.sh /path/to/emacs # 67 ERT tests, all batch-safe ``` ## Credits diff --git a/readme_zh.md b/readme_zh.md index a29fb8f..4b72895 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -50,7 +50,7 @@ cd ekp_c && make # 需要 C11 编译器,产出 ekp.dylib/.so/.dll ``` ```elisp -(ekp-c-module-load) ; 显示 "ekp-c module loaded (version 1.1, N threads)" +(ekp-c-module-load) ; 显示 "ekp-c module loaded (version 1.4, N threads)" ``` 加载后(`ekp-use-c-module` 默认为 `t`)所有排版调用自动走 C 引擎。 @@ -77,6 +77,21 @@ Elisp 与 C 两个引擎的输出**完全一致**;Elisp 是永远可用的后备 `ekp-region-margin-pixel`(默认 2)是从窗口宽度中扣除的取整安全边距。 +大 buffer(超过 `ekp-auto-justify-lazy-threshold` 字符,默认 2 万) +自动改为可视优先重排:屏幕内的部分同步完成(约 15ms),其余在空闲 +时后台分块补齐。 + +各 mode 的 verbatim 保护预设: + +```elisp +(add-hook 'org-mode-hook + (lambda () + (setq-local ekp-region-skip-faces ekp-region-org-skip-faces))) +(add-hook 'markdown-mode-hook + (lambda () + (setq-local ekp-region-skip-faces ekp-region-markdown-skip-faces))) +``` + ### 保护代码块与 verbatim 文本 - 段落级:携带 `ekp-verbatim` 文本属性(`M-x ekp-verbatim-region`)、 @@ -190,7 +205,7 @@ Silicon 测得;方法见 DEVELOPER_ZH.md: ## 测试 ```bash -tests/run-tests.sh /path/to/emacs # 36 个 ERT 测试,全部支持 batch +tests/run-tests.sh /path/to/emacs # 67 个 ERT 测试,全部支持 batch ``` ## 致谢 diff --git a/tests/ekp-region-tests.el b/tests/ekp-region-tests.el index 36b0036..eac82d8 100644 --- a/tests/ekp-region-tests.el +++ b/tests/ekp-region-tests.el @@ -255,6 +255,36 @@ the displaying WINDOW, with an arbitrary buffer current." (remove-text-properties 0 (length fresh) '(ekp-justified nil) fresh) (should (equal-including-properties got fresh)))))) + +(ert-deftest ekp-region-test-lazy-reflow-equals-oneshot () + "Visible-first chunked re-flow converges to the one-shot result." + (let ((ekp-auto-justify-lazy-threshold 100) + (ekp-auto-justify-chunk-size 3) + (text (mapconcat #'identity + (make-list 12 "段落内容 some words 足够长会换行的样子") + "\n"))) + (ekp-region-test--with-text text + (cl-letf (((symbol-function 'ekp-region--window-pixel) + (lambda (&optional _) 60)) + ((symbol-function 'ekp-region--visible-span) + (lambda () (cons (point-min) (min (point-max) 80))))) + (ekp-auto-justify-mode 1) + (ekp-region--reflow (current-buffer) 50) + (should ekp-region--pending) + ;; drain the background queue synchronously + (let ((guard 0)) + (while (and ekp-region--pending (< guard 100)) + (when (timerp ekp-region--chunk-timer) + (cancel-timer ekp-region--chunk-timer) + (setq ekp-region--chunk-timer nil)) + (ekp-region--process-chunk (current-buffer)) + (setq guard (1+ guard)))) + (should-not ekp-region--pending) + (let ((lazy (buffer-string))) + (ekp-auto-justify-mode -1) + (ekp-justify-region (point-min) (point-max) 50) + (should (equal-including-properties (buffer-string) lazy))))))) + (provide 'ekp-region-tests) ;;; ekp-region-tests.el ends here