From dd731cfbbf77c4f475cc24432efea463fa20166c Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Mon, 24 Aug 2026 02:08:53 +0800 Subject: [PATCH] Optimize incremental mixed-owner rendering --- docs/user/ebox-api-reference.zh.md | 2 +- ebox-incremental.el | 8 +++++ ebox-measure.el | 57 ++++++++++++++++++++---------- ebox-surface.el | 56 ++++++++++++++++++++++++++--- ebox.el | 5 +-- tests/ebox-commit-tests.el | 30 ++++++++++++++++ tests/ebox-core-render-tests.el | 20 +++++++++++ 7 files changed, 153 insertions(+), 25 deletions(-) diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md index 318f649..ef5f8e3 100644 --- a/docs/user/ebox-api-reference.zh.md +++ b/docs/user/ebox-api-reference.zh.md @@ -168,7 +168,7 @@ property;`ebox-region-update` 接受其目标支持的可变 content/style/scr | Padding | `:padding`、`:padding-inline`、`:padding-block`;longhand `:padding-block-start`、`:padding-inline-end`、`:padding-block-end`、`:padding-inline-start`;别名 `:padding-top`、`:padding-right`、`:padding-bottom`、`:padding-left`,以及 `:padding-right-pixel`、`:padding-left-pixel`、`:padding-top-height`、`:padding-bottom-height` | | Margin | `:margin`、`:margin-inline`、`:margin-block`;longhand `:margin-block-start`、`:margin-inline-end`、`:margin-block-end`、`:margin-inline-start`;别名 `:margin-top`、`:margin-right`、`:margin-bottom`、`:margin-left`,以及 `:margin-right-pixel`、`:margin-left-pixel`、`:margin-top-height`、`:margin-bottom-height` | | Border | `:border`、`:border-top`、`:border-right`、`:border-bottom`、`:border-left`、`:border-width`、`:border-style`、`:border-color`;side longhand `:border-*-width`、`:border-*-style`、`:border-*-color`;兼容别名 `:border-top-p`、`:border-bottom-p`、`:border-left-pixel`、`:border-right-pixel` | -| Paint | `:color`、`:background-color` 和别名 `:bgcolor` | +| Paint | `:color`、`:background-color`、别名 `:bgcolor` | | Typography | `:font`、`:font-family`、`:font-height` 和别名 `:font-size`、`:font-weight`、`:font-slant` | | Text/layout | `:text-align`(`left`、`center`、`right`)、`:vertical-align`(`top`、`center`/`middle`、`bottom`)、`:overflow`(`scroll`、`hidden`、`visible`)、`:wrap-mode`(`word`、`char`、`kp` 或 nil)、`:visibility`(`visible` 或 `hidden`) | | Structural style | `:display` 会在 node/layout context 中计算;有效的 display tuple 通常由布局构造器选择。 | diff --git a/ebox-incremental.el b/ebox-incremental.el index f867d57..e16063f 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -8229,6 +8229,14 @@ result in the same owner-proof shape consumed by span publication." (list :root (plist-get prepared :root) :scope-node-ids scope-node-ids :report-base report + ;; A child Range replacement can legitimately change the separator + ;; owned by an adjacent sibling when its item count changes. Keep + ;; the fast scoped proof for stable topology, but let the surface + ;; publisher use its exact root fallback when that proof cannot + ;; establish the outside text invariant. + :on-mismatch + (and (> (or (plist-get prepared :range-replacement-count) 0) 0) + 'root) :projection-kind projection-kind :preserve-identities-p t :state-overrides diff --git a/ebox-measure.el b/ebox-measure.el index 0cbd632..ffae5d1 100644 --- a/ebox-measure.el +++ b/ebox-measure.el @@ -115,21 +115,30 @@ of pixel measurements it triggers do not each rebuild the display signature.") (fillarray ebox--string-pixel-width-cache-ring nil) (setq ebox--string-pixel-width-cache-ring-index 0)) +(defun ebox--string-pixel-width-visual-key (line factor) + "Return a width cache key for LINE at FACTOR when visual properties are constant." + (let ((end (length line))) + (list factor + (substring-no-properties line) + (cl-loop for property in '(face display) + for value = (and (> end 0) + (get-text-property 0 property line)) + when value append (list property value))))) + (defun ebox--string-pixel-width-cache-key (line factor) "Return a safe display-scoped measurement key for LINE at FACTOR." (let* ((end (length line)) - (properties (and (> end 0) (text-properties-at 0 line))) + ;; Ebox layout text uses `face' and `display' as its width-bearing + ;; properties. Ownership/region metadata is deliberately ignored; + ;; the other generic text properties are not emitted by Ebox's + ;; layout pipeline and would make this hot key scan needlessly broad. (constant-p - (= (or (next-property-change 0 line end) end) end))) - (cond - ((and constant-p (null properties)) - (list factor line)) - ((and constant-p - (= (length properties) 2) - (eq (car properties) 'face)) - (list factor - line - (cadr properties)))))) + (and (= (or (next-single-property-change 0 'face line end) end) + end) + (= (or (next-single-property-change 0 'display line end) end) + end)))) + (and constant-p + (ebox--string-pixel-width-visual-key line factor)))) (defun ebox--cached-string-pixel-width (line factor key) "Return LINE width at FACTOR through the render and display caches at KEY." @@ -161,7 +170,9 @@ of pixel measurements it triggers do not each rebuild the display signature.") Text characters follow the current buffer text scale. Fixed display spaces keep their absolute pixel width." (ebox--ensure-display-cache-current) - (let* ((line (car (split-string (or string "") "\n"))) + (let* ((source (or string "")) + (newline (and (stringp source) (string-match "\n" source))) + (line (if newline (substring source 0 newline) source)) (factor (ebox--text-scale-factor)) (pos 0) (end (length line)) @@ -171,17 +182,27 @@ keep their absolute pixel width." (ebox--cached-string-pixel-width line factor cache-key) (while (< pos end) (let* ((display (get-text-property pos 'display line)) - (next (or (next-single-property-change - pos 'display line end) - end)) + (next + (cl-loop for property in + '(face display) + minimize (or (next-single-property-change + pos property line end) + end))) + (segment (substring line pos next)) + ;; The segment ends at every visual property change, so its + ;; width key can skip the second constant-property scan. + (segment-key + (ebox--string-pixel-width-visual-key segment factor)) (space-width (ebox--display-space-width display))) (setq total (+ total (if space-width (* space-width (- next pos)) - (ebox--scaled-string-pixel-width - (substring line pos next) - factor)))) + (if segment-key + (ebox--cached-string-pixel-width + segment factor segment-key) + (ebox--scaled-string-pixel-width + segment factor))))) (setq pos next))) total))) diff --git a/ebox-surface.el b/ebox-surface.el index 4bd4df0..2eca76b 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -2077,6 +2077,12 @@ causes the caller to use the ordinary full projection." (ebox-surface--formatting-context-reflow-output buffer state))))) (old-fragments (plist-get previous-state :surface-fragments)) + (old-text + (and (buffer-live-p buffer) + (with-current-buffer buffer + (save-restriction + (widen) + (buffer-substring (point-min) (point-max)))))) (old-by-address (make-hash-table :test #'equal)) (old-by-local-address (make-hash-table :test #'equal))) (when (and output old-fragments paint-owner-ids) @@ -2116,6 +2122,13 @@ causes the caller to use the ordinary full projection." (old-fragment (or (and address (gethash address old-by-address)) (and (= (length matches) 1) (car matches)))) + (old-fragment-text + (and old-text old-fragment + (integerp (plist-get old-fragment :start)) + (integerp (plist-get old-fragment :end)) + (substring old-text + (plist-get old-fragment :start) + (plist-get old-fragment :end)))) (affected-p (cl-some (lambda (entry) (memq (cdr entry) paint-owner-ids)) @@ -2141,10 +2154,45 @@ causes the caller to use the ordinary full projection." (plist-get old-fragment :paint-role-ids)) roles))) - (push (ebox-surface--repaint-fragment - copy previous-state state - old-face-cache new-face-cache) - pieces))) + ;; Geometry reflow still produces a complete candidate text, + ;; but an unaffected fragment whose text/properties are + ;; byte-for-byte identical and whose semantic face + ;; contributions are unchanged already has the correct + ;; composed face. Reuse its candidate metadata without + ;; rebuilding it; changed/paint-owned fragments retain the + ;; existing baseline-safe repaint path. + (let* ((old-roles (plist-get copy :old-paint-role-ids)) + (new-roles (plist-get copy :paint-role-ids)) + (reusable-p + (and old-fragment + (not affected-p) + old-fragment-text + (equal-including-properties + old-fragment-text text) + (equal old-roles new-roles) + (let ((old-faces + (or (gethash old-roles old-face-cache) + (let ((value + (ebox-surface--face-contributions + previous-state old-roles))) + (puthash old-roles value old-face-cache) + value))) + (new-faces + (or (gethash new-roles new-face-cache) + (let ((value + (ebox-surface--face-contributions + state new-roles))) + (puthash new-roles value new-face-cache) + value)))) + (equal old-faces new-faces))))) + (push (if reusable-p + (progn + (cl-remf copy :old-paint-role-ids) + copy) + (ebox-surface--repaint-fragment + copy previous-state state + old-face-cache new-face-cache)) + pieces)))) (let* ((fragments (nreverse pieces)) (rendered (apply #'concat diff --git a/ebox.el b/ebox.el index 5fc8dfd..22468ee 100644 --- a/ebox.el +++ b/ebox.el @@ -404,7 +404,6 @@ caller, such as the Playground preview, renders against a concrete window.") ;; 上下边框由于 Emacs 限制,用 overline/underline 实现,不支持设置宽度 :border-top-p nil :border-top-color nil :border-bottom-p nil :border-bottom-color nil - :color nil :bgcolor nil :font nil :font-family nil :font-height nil @@ -4588,7 +4587,9 @@ Return the successful publication report stored by `ebox-buffer-update-report'." buffer source scope-node-ids (plist-get commit-input :report-base) (plist-get commit-input :state-overrides) - callback nil nil + callback + (plist-get commit-input :on-mismatch) + nil (plist-get commit-input :projection-kind) t participant) (ebox-surface-mount-buffer diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 6df633a..b502bef 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -1473,6 +1473,36 @@ remain retained identities." (ebox-render (ebox--buffer-root-node buffer)))))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-commit-mixed-owner-reflow-reuses-unchanged-fragments () + "Skip face recomposition for unchanged fragments in a mixed projection." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-mixed-owner-reuse* ") + (ebox-commit-test--mixed-owner-root + "left-old" "right-old" "#111111" "#222222" "#333333"))) + (surface (with-current-buffer buffer ebox-surface--buffer-surface)) + (old-state (tp-surface-client-state surface)) + (fragment-count (length (plist-get old-state :surface-fragments))) + (repaints 0) + (original (symbol-function 'ebox-surface--repaint-fragment))) + (unwind-protect + (progn + (cl-letf (((symbol-function 'ebox-surface--repaint-fragment) + (lambda (&rest args) + (cl-incf repaints) + (apply original args)))) + (should (eq (plist-get + (ebox-commit + buffer + (ebox-commit-test--mixed-owner-candidate + buffer "left-new" "right-new" + "#AAAAAA" "#BBBBBB" "#CCCCCC")) + :projection-kind) + 'mixed-owner-reflow))) + (should (> fragment-count 0)) + (should (< repaints fragment-count))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + (provide 'ebox-commit-tests) ;;; ebox-commit-tests.el ends here diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 87907f0..b087afb 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -4543,6 +4543,26 @@ (should (= (ebox--string-max-pixel-width exact) 120)) (should (plist-get exact-parent :ebox-content-width-exact-p)))) +(ert-deftest ebox-string-pixel-width-cache-ignores-ebox-ownership-metadata () + "Reuse visual width measurements across Ebox-owned text properties." + (let ((line (propertize "cached row" + 'ebox-content 7 + 'ebox-content-idx 0 + 'ebox-content-owner 7 + 'ebox-content-owners '(7))) + (calls 0) + (original (symbol-function 'ebox--scaled-string-pixel-width))) + (ebox--clear-string-pixel-width-cache) + (cl-letf (((symbol-function 'ebox--scaled-string-pixel-width) + (lambda (&rest args) + (cl-incf calls) + (apply original args)))) + (let ((first (ebox--string-pixel-width line)) + (second (ebox--string-pixel-width (copy-sequence line)))) + (should (> first 0)) + (should (= first second)))) + (should (= calls 1)))) + (ert-deftest ebox-render-caches-lazy-box-content-within-render-pass () "A lazy child layout should be rendered once per box/context in one pass." (ebox-test--reset-runtime-state)