feat: visible-first lazy re-flow for large buffers; packaging polish

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 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-26 23:22:56 +08:00
parent c4d7fdf8bb
commit dc2f6da846
6 changed files with 186 additions and 10 deletions

View File

@ -3,7 +3,6 @@
;; Copyright (C) 2024
;; Author: emacs-kp contributors
;; Keywords: text, hyphenation, typesetting
;; Package-Requires: ((emacs "27.1"))
;;; Commentary:

View File

@ -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

2
ekp.el
View File

@ -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"))

View File

@ -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

View File

@ -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
```
## 致谢

View File

@ -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