perf: coalesce viewport resize delivery

This commit is contained in:
Kinneyzhang 2026-08-25 22:45:13 +08:00
parent 175ae2ed4e
commit d58ec51650
9 changed files with 204 additions and 53 deletions

View File

@ -431,6 +431,12 @@ context. For pure rendering, callers may instead bind `ebox-viewport-width`
in pixels and `ebox-viewport-height` in lines around the render. Do not mutate
a published node tree in place.
Visible mounted buffers follow their displaying window automatically. Ebox
coalesces continuous size events and publishes only the latest sample after
`ebox-viewport-resize-delay` (0.05 seconds by default).
`ebox-viewport-window-width` returns the same display-safe pixel width used by
that controller, so hosts must not install a second resize hook or width rule.
## 7. Selectors
`ebox-selector-parse` delegates to ECSS and returns its structured AST.
@ -516,6 +522,7 @@ The main customization variables are:
| Lazy scroll | `ebox-scroll-lazy-prefix-lookahead-lines` 8; `ebox-scroll-lazy-idle-prefetch-lines` 128; `ebox-scroll-lazy-idle-prefetch-slice-lines` 16; `ebox-scroll-lazy-idle-prefetch-delay` 0.15 |
| Runtime prewarm | `ebox-runtime-idle-prewarm` t; `ebox-runtime-idle-prewarm-delay` 0.1; `ebox-runtime-idle-prewarm-prefix-resume-delay` 2.0; `ebox-runtime-idle-prewarm-slice-size` 32; `ebox-native-buffer-scroll` t (initial/visible-window handoff only; strict root-owner proof) |
| Predicted reflow | `ebox-runtime-idle-reflow-cache-prewarm` t; `ebox-runtime-idle-reflow-cache-prewarm-delay` 0.15 |
| Viewport delivery | `ebox-viewport-resize-delay` 0.05; continuous window events replace one pending sample per mounted buffer |
| Reflow GC | `ebox-reflow-cache-prewarm-gc-cons-threshold` `auto`; `ebox-reflow-cache-prewarm-gc-auto-frame-budget` 0.2; `ebox-reflow-cache-prewarm-gc-auto-min-threshold` 64 MiB; `ebox-reflow-cache-prewarm-gc-auto-max-threshold` 1 GiB; `ebox-reflow-cache-prewarm-gc-auto-initial-threshold` 512 MiB; `ebox-reflow-cache-prewarm-gc-auto-target-layouts` 24; `ebox-reflow-cache-prewarm-gc-cons-percentage` 0.1 |
| Visual verification | `ebox-visual-check-output-dir` points to the temporary directory used for optional screenshots and reports. |
@ -583,7 +590,7 @@ style-rule functions immediately below are public module-level style APIs.
| `ebox-flex`, `ebox-flex-item` | Build flex containers and item metadata. |
| `ebox-grid`, `ebox-grid-fr`, `ebox-grid-item` | Build Grid layouts, fractional tracks, and placement metadata. |
| `ebox-render`, `ebox-render-to-buffer`, `ebox-display-buffer` | Pure materialization, retained mount, and display wrapper. |
| `ebox-commit`, `ebox-buffer-update-report`, `ebox-rerender-buffer-with-context` | Atomic root commit, report lookup, and viewport-context rerender. |
| `ebox-commit`, `ebox-buffer-update-report`, `ebox-rerender-buffer-with-context`, `ebox-viewport-window-width` | Atomic root commit, report lookup, viewport-context rerender, and the shared display-safe width sampler. |
| `ebox-region-ids`, `ebox-region-resolve`, `ebox-region-update` | Region mapping, logical handle lookup, and direct update. |
| `ebox-child-range`, `ebox-range-ref-present-p`, `ebox-candidate-begin`, `ebox-candidate-replace`, `ebox-candidate-replace-range-ref`, `ebox-candidate-replace-root`, `ebox-candidate-replace-host-ref`, `ebox-candidate-patch-host-paint` | Persistent material child segments, backend-anchor lookup, subtree replacement, and retained Host paint transactions. |
| `ebox-host-ref-bounds`, `ebox-host-ref-position` | Live host-reference bounds and first position. |

View File

@ -392,6 +392,11 @@ report若后续 phase 失败rollback 最多一次接收同一个 report
render 外部动态绑定 `ebox-viewport-width`(像素)与 `ebox-viewport-height`
(行数)。不要原地修改已经发布的 node tree。
可见的 mounted buffer 会自动跟随展示它的 window。Ebox 合并连续 size event
并在 `ebox-viewport-resize-delay`(默认 0.05 秒)后只发布最新 sample。
`ebox-viewport-window-width` 返回 controller 使用的同一个显示安全像素宽度;
宿主不应再安装第二套 resize hook 或宽度算法。
## 7. Selector
`ebox-selector-parse` 委托 ECSS 并返回结构化 AST
@ -468,6 +473,7 @@ text scale 和固定 `display` space它也是布局使用的公共测量原
| Lazy scroll | `ebox-scroll-lazy-prefix-lookahead-lines` 8`ebox-scroll-lazy-idle-prefetch-lines` 128`ebox-scroll-lazy-idle-prefetch-slice-lines` 16`ebox-scroll-lazy-idle-prefetch-delay` 0.15 |
| Runtime prewarm | `ebox-runtime-idle-prewarm` t`ebox-runtime-idle-prewarm-delay` 0.1`ebox-runtime-idle-prewarm-prefix-resume-delay` 2.0`ebox-runtime-idle-prewarm-slice-size` 32`ebox-native-buffer-scroll` t仅 initial/visible-window handoff严格 root-owner proof |
| 预测 reflow | `ebox-runtime-idle-reflow-cache-prewarm` t`ebox-runtime-idle-reflow-cache-prewarm-delay` 0.15 |
| Viewport delivery | `ebox-viewport-resize-delay` 0.05;连续 window event 对每个 mounted buffer 只保留一个最新 sample |
| Reflow GC | `ebox-reflow-cache-prewarm-gc-cons-threshold` `auto``ebox-reflow-cache-prewarm-gc-auto-frame-budget` 0.2`ebox-reflow-cache-prewarm-gc-auto-min-threshold` 64 MiB`ebox-reflow-cache-prewarm-gc-auto-max-threshold` 1 GiB`ebox-reflow-cache-prewarm-gc-auto-initial-threshold` 512 MiB`ebox-reflow-cache-prewarm-gc-auto-target-layouts` 24`ebox-reflow-cache-prewarm-gc-cons-percentage` 0.1 |
| Visual verification | `ebox-visual-check-output-dir` 指向可选 screenshot/report 使用的临时目录。 |
@ -531,7 +537,7 @@ style API。
| `ebox-flex`、`ebox-flex-item` | 构建 Flex 容器与 item metadata。 |
| `ebox-grid`、`ebox-grid-fr`、`ebox-grid-item` | 构建 Grid、分数轨道和 placement metadata。 |
| `ebox-render`、`ebox-render-to-buffer`、`ebox-display-buffer` | 纯 materialization、retained mount 与展示包装。 |
| `ebox-commit`、`ebox-buffer-update-report`、`ebox-rerender-buffer-with-context` | 原子根提交、报告查询和 viewport-context rerender。 |
| `ebox-commit`、`ebox-buffer-update-report`、`ebox-rerender-buffer-with-context`、`ebox-viewport-window-width` | 原子根提交、报告查询、viewport-context rerender 与统一的显示安全宽度采样。 |
| `ebox-region-ids`、`ebox-region-resolve`、`ebox-region-update` | Region 映射、logical handle 查询与直接更新。 |
| `ebox-child-range`、`ebox-range-ref-present-p`、`ebox-candidate-begin`、`ebox-candidate-replace`、`ebox-candidate-replace-range-ref`、`ebox-candidate-replace-root`、`ebox-candidate-replace-host-ref`、`ebox-candidate-patch-host-paint` | 持久 material child segment、backend anchor 查询、子树替换与 retained Host paint transaction。 |
| `ebox-host-ref-bounds`、`ebox-host-ref-position` | Host-reference 的 live bounds 与首位置。 |

103
ebox-viewport.el Normal file
View File

@ -0,0 +1,103 @@
;;; ebox-viewport.el --- Coalesced Emacs viewport delivery -*- lexical-binding: t; -*-
;;; Commentary:
;; Emacs owns window events; Ebox owns the mounted viewport. This module is
;; the single bridge between them. Size hooks only sample the latest visible
;; dimensions. One deferred callback per buffer publishes the newest sample,
;; so continuous frame dragging cannot enqueue synchronous layout transactions.
;;; Code:
(require 'cl-lib)
(require 'ebox-surface)
(declare-function ebox--buffer-render-state "ebox-incremental" (buffer))
(declare-function ebox-rerender-buffer-with-context
"ebox" (buffer viewport-width &optional viewport-height))
(defcustom ebox-viewport-resize-delay 0.05
"Seconds after the latest size event before publishing its viewport.
Continuous size events replace one pending sample for each mounted buffer."
:type 'number
:group 'ebox)
(defvar ebox--viewport-resize-pending-table (make-hash-table :test #'eq)
"Buffer-keyed latest viewport samples awaiting publication.")
(defvar ebox--viewport-resize-generation 0
"Monotonic identity for pending viewport samples.")
(defun ebox-viewport-window-width (window)
"Return Ebox's display-safe viewport width for live WINDOW."
(and (window-live-p window)
(ebox-surface--window-content-width window)))
(defun ebox--cancel-buffer-viewport-resize (buffer)
"Cancel BUFFER's pending viewport publication."
(when-let* ((pending (gethash buffer ebox--viewport-resize-pending-table)))
(let ((timer (aref pending 4)))
(when (timerp timer) (cancel-timer timer)))
(remhash buffer ebox--viewport-resize-pending-table))
nil)
(defun ebox--viewport-resize-apply (buffer generation)
"Publish BUFFER's pending viewport identified by GENERATION."
(when-let* ((pending (gethash buffer ebox--viewport-resize-pending-table)))
(when (= generation (aref pending 0))
(remhash buffer ebox--viewport-resize-pending-table)
(let ((window (aref pending 1))
(width (aref pending 2))
(height (aref pending 3)))
(when (and (buffer-live-p buffer)
(window-live-p window)
(eq (window-buffer window) buffer)
(ebox-surface-buffer-mounted-p buffer))
(let ((state (ebox--buffer-render-state buffer)))
(unless (and (= width (or (plist-get state :viewport-width) -1))
(= height (or (plist-get state :viewport-height) -1)))
(ebox-rerender-buffer-with-context buffer width height))))))))
(defun ebox--schedule-buffer-viewport-resize
(buffer window width height)
"Replace BUFFER's pending viewport with WINDOW, WIDTH, and HEIGHT."
(ebox--cancel-buffer-viewport-resize buffer)
(let* ((generation (cl-incf ebox--viewport-resize-generation))
(pending (vector generation window width height nil)))
(puthash buffer pending ebox--viewport-resize-pending-table)
(aset pending 4
(run-at-time
(max 0 ebox-viewport-resize-delay) nil
#'ebox--viewport-resize-apply buffer generation)))
buffer)
(defun ebox--window-size-change (frame)
"Sample latest viewports for mounted Ebox buffers visible in FRAME."
(when (and (frame-live-p frame) (not noninteractive))
(let ((seen (make-hash-table :test #'eq)))
(dolist (window (window-list frame 'no-minibuf))
(when (window-live-p window)
(let ((buffer (window-buffer window)))
(unless (gethash buffer seen)
(puthash buffer t seen)
(when (and (buffer-live-p buffer)
(ebox-surface-buffer-mounted-p buffer))
(let* ((state (ebox--buffer-render-state buffer))
(width (ebox-viewport-window-width window))
(height (window-body-height window)))
(when (and width
state
(not (and
(= width
(or (plist-get state :viewport-width) -1))
(= height
(or (plist-get state :viewport-height)
-1)))))
(ebox--schedule-buffer-viewport-resize
buffer window width height)))))))))))
(when (boundp 'window-size-change-functions)
(add-hook 'window-size-change-functions #'ebox--window-size-change))
(provide 'ebox-viewport)
;;; ebox-viewport.el ends here

42
ebox.el
View File

@ -26,7 +26,8 @@
'("ebox-cache.el" "ebox-style.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el")
"Active Ebox Lisp sources compiled by `ebox-byte-compile'.")
@ -49,6 +50,7 @@
(require 'ebox-buffer-backend)
(require 'ebox-incremental)
(require 'ebox-surface)
(require 'ebox-viewport)
(require 'ebox-dsl)
(require 'ebox-selector)
@ -2742,42 +2744,6 @@ that was rendered off-window cannot miss the native-scroll handoff."
(when (boundp 'window-state-change-functions)
(add-hook 'window-state-change-functions #'ebox--window-state-change))
(defvar ebox--window-size-change-in-progress nil
"Non-nil while visible Ebox buffers are synchronizing their viewport.")
(defun ebox--window-size-change (frame)
"Synchronize visible Ebox buffers after FRAME changes size.
Ebox stores the containing-block viewport in its mounted surface. A frame
resize must publish the new window content width before the next render;
otherwise responsive Flex/Grid trees keep using the width from mount time.
One mounted surface represents one viewport, so when a buffer is visible in
multiple windows the first window in FRAME supplies the shared dimensions."
(when (and (frame-live-p frame)
(not noninteractive)
(not ebox--window-size-change-in-progress))
(let ((ebox--window-size-change-in-progress t)
(seen (make-hash-table :test #'eq)))
(dolist (window (window-list frame 'no-minibuf))
(when (and (window-live-p window)
(not (gethash (window-buffer window) seen)))
(let ((buffer (window-buffer window)))
(puthash buffer t seen)
(when (and (buffer-live-p buffer)
(ebox--buffer-render-state buffer)
(ebox-surface-buffer-mounted-p buffer))
(let* ((state (ebox--buffer-render-state buffer))
(width (ebox-surface--window-content-width window))
(height (window-body-height window)))
(unless (and (= width (or (plist-get state :viewport-width)
-1))
(= height (or (plist-get state :viewport-height)
-1)))
(ebox-rerender-buffer-with-context buffer width height))))))))))
(when (boundp 'window-size-change-functions)
(add-hook 'window-size-change-functions #'ebox--window-size-change))
(defun ebox--runtime-prewarm-record-viewport-node
(job node width-allowed)
"Record NODE's direct viewport dependencies in JOB.
@ -3882,6 +3848,7 @@ With prefix ARG, scroll by that many content lines."
"Remove all runtime caches owned by BUFFER.
Defaults to the current buffer."
(let ((buffer (or buffer (current-buffer))))
(ebox--cancel-buffer-viewport-resize buffer)
(ebox--cancel-buffer-runtime-prewarm buffer)
(when-let ((root (ebox--buffer-root-node buffer)))
(ebox--clear-region-runtime-caches
@ -4716,6 +4683,7 @@ through dirty-set and patch-set execution before falling back to root rerender."
ebox-stack
ebox-string-pixel-width
ebox-update-selector
ebox-viewport-window-width
ebox-wheel-scroll-down
ebox-wheel-scroll-up)
"Stable core Ebox entry points available to applications and tooling.

View File

@ -4,6 +4,11 @@
(require 'ebox-child-range)
(require 'ebox)
;; This suite locks child-range and Elisp projection semantics. Native frame
;; execution is verified separately and must not replace the named fallback
;; strategies merely because a developer has built the optional module.
(setq ebox-native-reflow-module-path nil)
(defconst ebox-child-range-test--root
(expand-file-name ".." (file-name-directory (or load-file-name buffer-file-name)))
"Ebox repository root for source audits.")

View File

@ -5,6 +5,12 @@
(require 'ebox)
(require 'ebox-native-commit)
;; These tests lock the named Elisp projection proofs. Native commit has its
;; own focused contract tests below; disable runtime module discovery here so
;; a locally built optional module cannot silently replace paint/span/mixed
;; plans with `native-frame' and make this suite environment-dependent.
(setq ebox-native-reflow-module-path nil)
(defun ebox-commit-test--buffer-string (buffer)
"Return BUFFER's complete propertized contents."
(with-current-buffer buffer

View File

@ -10,6 +10,11 @@
(load-file (expand-file-name "../ebox.el" (file-name-directory load-file-name)))
;; Core tests assert named Elisp render/patch strategies. Keep optional local
;; native artifacts from replacing those strategies; native execution has
;; separate module and integration gates.
(setq ebox-native-reflow-module-path nil)
(defvar ebox--scroll-window-initial-lookahead-lines-override nil)
(defun ebox-test--reset-runtime-state ()

View File

@ -51,7 +51,8 @@
(dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure
ebox-fragment ebox-layout ebox-flex ebox-grid
ebox-buffer-backend ebox-incremental
ebox-surface ebox-dsl ebox-selector))
ebox-surface ebox-viewport ebox-dsl
ebox-selector))
(should (featurep feature))))
(ert-deftest ebox-facade-exposes-native-workflow-without-loading-it ()
@ -70,8 +71,8 @@
"ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-dsl.el"
"ebox-selector.el" "ebox.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el"))))
(ert-deftest ebox-surface-depends-only-on-public-tp-api ()

View File

@ -171,11 +171,15 @@
(when (get-buffer " *other-window*")
(kill-buffer " *other-window*")))))
(ert-deftest ebox-window-size-change-publishes-visible-viewport ()
"A live frame resize updates the mounted surface's containing block."
(ert-deftest ebox-window-size-change-coalesces-to-latest-visible-viewport ()
"Continuous frame changes publish only the latest sampled viewport."
(let* ((buffer (generate-new-buffer " *ebox-window-size-change*"))
(window (selected-window))
(old-buffer (window-buffer window))
(sampled-height (window-body-height window))
(sampled-widths (number-sequence 240 430 10))
scheduled
cancelled
calls)
(unwind-protect
(progn
@ -186,18 +190,58 @@
((symbol-function 'ebox-surface-buffer-mounted-p)
(lambda (_buffer) t))
((symbol-function 'ebox-surface--window-content-width)
(lambda (_window) 240))
((symbol-function 'window-body-height)
(lambda (_window &optional _pixelwise) 20))
(lambda (_window) (pop sampled-widths)))
((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (target width height)
(setq calls (list target width height))))
(push (list target width height) calls)))
((symbol-function 'run-at-time)
(lambda (delay _repeat function &rest arguments)
(let ((timer (intern (format "ebox-resize-timer-%d"
(1+ (length scheduled))))))
(push (list timer delay function arguments) scheduled)
timer)))
((symbol-function 'timerp)
(lambda (value)
(string-prefix-p "ebox-resize-timer-"
(symbol-name value))))
((symbol-function 'cancel-timer)
(lambda (timer) (push timer cancelled)))
(noninteractive nil))
(ebox--window-size-change (selected-frame))
(should (equal calls (list buffer 240 20)))))
(dotimes (_index 20)
(ebox--window-size-change (selected-frame)))
(should-not calls)
(should (= (length scheduled) 20))
(should (= (length cancelled) 19))
(pcase-let ((`(,_timer ,delay ,function ,arguments)
(car scheduled)))
(should (= delay ebox-viewport-resize-delay))
(apply function arguments))
(should (equal calls
(list (list buffer 430 sampled-height))))))
(set-window-buffer window old-buffer)
(when (boundp 'ebox--viewport-resize-pending-table)
(clrhash ebox--viewport-resize-pending-table))
(kill-buffer buffer))))
(ert-deftest ebox-buffer-cleanup-cancels-pending-viewport-resize ()
"A dead mounted buffer cannot retain a viewport timer or pending sample."
(let ((buffer (generate-new-buffer " *ebox-viewport-cleanup*"))
(timer 'ebox-test-viewport-cleanup-timer)
cancelled)
(unwind-protect
(progn
(puthash buffer (vector 1 (selected-window) 400 20 timer)
ebox--viewport-resize-pending-table)
(cl-letf (((symbol-function 'timerp)
(lambda (value) (eq value timer)))
((symbol-function 'cancel-timer)
(lambda (value) (setq cancelled value))))
(with-current-buffer buffer (ebox--cleanup-current-buffer)))
(should (eq cancelled timer))
(should-not (gethash buffer ebox--viewport-resize-pending-table)))
(remhash buffer ebox--viewport-resize-pending-table)
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun ebox-surface-test--hash-fingerprint (table)
"Return a stable content fingerprint for hash TABLE.
The fingerprint checks entries rather than only table identity, so a failed
@ -732,10 +776,13 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(ert-deftest ebox-surface-plan-stays-free-of-runtime-state ()
"The surface plan should not contain markers or runtime handles."
(require 'ebox-native-commit)
(let ((original (symbol-function 'tp-surface-result-create))
(original-owned (symbol-function 'tp-surface-result-create-owned))
captured client-state)
(cl-letf (((symbol-function 'tp-surface-result-create)
(cl-letf (((symbol-function 'ebox-native-commit-render)
(lambda (&rest _) nil))
((symbol-function 'tp-surface-result-create)
(lambda (plan &optional state)
(setq captured plan
client-state state)
@ -753,7 +800,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(let* ((fragments-plan (car (tp-surface-plan-children captured)))
(text-plan (car (tp-surface-plan-children fragments-plan)))
(plan-text (tp-surface-plan-text text-plan))
(state-fragments (plist-get client-state :surface-fragments))
(state-fragments
(ebox-surface--materialized-fragment-ledger client-state))
(owner-position
(cl-loop for position from 0 below (length plan-text)
when (get-text-property position
@ -1323,7 +1371,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(plist-put node :key 'growing-source)
node))
(let ((original-render (symbol-function 'ebox-render)))
(cl-letf (((symbol-function 'ebox-render)
(cl-letf (((symbol-function 'ebox-native-commit-render)
(lambda (&rest _) nil))
((symbol-function 'ebox-render)
(lambda (node)
(cond
((equal (plist-get node :key) 'stable-source)