perf: eliminate cold-scroll flicker and GC stalls

This commit is contained in:
Kinneyzhang 2026-08-27 16:42:59 +08:00
parent 0c7e7b974a
commit 9f48b2f283
3 changed files with 148 additions and 74 deletions

View File

@ -223,6 +223,11 @@ owned; no per-fragment substring is allocated."
(setq line-index (1+ line-index)))
(nreverse result))))
(defun ebox-surface--scroll-lines-owned-p (lines)
"Return non-nil when every rendered line has an owned fragment template."
(and lines
(cl-every #'ebox-surface--scroll-line-fragment-template lines)))
(defun ebox-surface--style-state-table (previous-state)
"Copy retained style state from PREVIOUS-STATE into a weak table."
(let ((table (make-hash-table :test 'eq :weakness 'key)))
@ -3310,12 +3315,14 @@ box with wrapper chrome falls back to the retained wrapper renderer."
scroll-state))))
(if (and visible (cadr visible))
(let* ((lines (cadr visible))
(output (ebox-lines-join lines))
(fragment-data
(ebox-surface--scroll-fragment-data lines output)))
(when (and fragment-data
(output (ebox-lines-join lines)))
(when (and (ebox-surface--scroll-lines-owned-p lines)
(plist-get state :retained-scroll-content-p))
(plist-put state :scroll-patch-fragment-data fragment-data))
;; Retain immutable line handles and stream their cached role
;; runs directly into ownership planning. Materializing one
;; fragment plist per visible property run on every wheel
;; event made scroll allocation O(all visible fragments).
(plist-put state :scroll-patch-lines lines))
(when (> (length output) 0)
(add-text-properties
0 (length output)
@ -3745,50 +3752,83 @@ fragment's complete paint-owner ancestor chain."
(push copy metadata)
(setq offset (+ offset length))))))
(defun ebox-surface--owned-ranges
(context leaf fragments state node-objects region-objects
&optional attach-p)
"Attach merged owner ranges for FRAGMENTS to candidate LEAF.
(defun ebox-surface--owned-ranges-from-runs
(context leaf state node-objects region-objects attach-p visit-runs)
"Attach merged ownership produced by VISIT-RUNS to candidate LEAF.
VISIT-RUNS receives one callback accepting ROLE-IDS, START, and END.
Adjacent ranges merge only when both their owner and opaque tags match."
(let ((active (make-hash-table :test #'eq))
(owner-cache (make-hash-table :test #'equal))
(offset 0)
ranges)
(dolist (fragment fragments)
(let* ((role-ids (plist-get fragment :role-ids))
(owners (gethash role-ids owner-cache 'ebox/no-owners))
(source-p (plist-get fragment :text-source-p))
(start (if source-p
(plist-get fragment :start)
offset))
(end (if source-p
(plist-get fragment :end)
(+ offset (length (plist-get fragment :text))))))
(when (eq owners 'ebox/no-owners)
(setq owners
(ebox-surface--fragment-owners
role-ids state node-objects region-objects))
(puthash role-ids owners owner-cache))
(dolist (owner owners)
(let* ((object (nth 0 owner))
(tags (nth 1 owner))
(tag-ranges (or (gethash object active)
(let ((table (make-hash-table :test #'equal)))
(puthash object table active)
table)))
(current (gethash tags tag-ranges)))
(if (and current (= (plist-get current :end) start))
(plist-put current :end end)
(let ((range (list :object object :start start :end end
:tags tags)))
(push range ranges)
(puthash tags range tag-ranges)))))
(setq offset (if source-p end (+ offset (length (plist-get fragment :text)))))))
(funcall
visit-runs
(lambda (role-ids start end)
(let ((owners (gethash role-ids owner-cache 'ebox/no-owners)))
(when (eq owners 'ebox/no-owners)
(setq owners
(ebox-surface--fragment-owners
role-ids state node-objects region-objects))
(puthash role-ids owners owner-cache))
(dolist (owner owners)
(let* ((object (nth 0 owner))
(tags (nth 1 owner))
(tag-ranges
(or (gethash object active)
(let ((table (make-hash-table :test #'equal)))
(puthash object table active)
table)))
(current (gethash tags tag-ranges)))
(if (and current (= (plist-get current :end) start))
(plist-put current :end end)
(let ((range (list :object object :start start :end end
:tags tags)))
(push range ranges)
(puthash tags range tag-ranges))))))))
(setq ranges (nreverse ranges))
(when attach-p
(tp-object-attach-content-ranges-owned context leaf ranges))
ranges))
(defun ebox-surface--owned-ranges
(context leaf fragments state node-objects region-objects
&optional attach-p)
"Attach merged owner ranges for FRAGMENTS to candidate LEAF."
(ebox-surface--owned-ranges-from-runs
context leaf state node-objects region-objects attach-p
(lambda (visit)
(let ((offset 0))
(dolist (fragment fragments)
(let* ((source-p (plist-get fragment :text-source-p))
(start (if source-p
(plist-get fragment :start)
offset))
(end (if source-p
(plist-get fragment :end)
(+ offset (length (plist-get fragment :text))))))
(funcall visit (plist-get fragment :role-ids) start end)
(setq offset
(if source-p
end
(+ offset (length (plist-get fragment :text)))))))))))
(defun ebox-surface--scroll-owned-ranges
(context leaf lines state node-objects region-objects attach-p)
"Attach merged owner ranges for retained scroll LINES to candidate LEAF."
(ebox-surface--owned-ranges-from-runs
context leaf state node-objects region-objects attach-p
(lambda (visit)
(let ((offset 0))
(dolist (line lines)
(let ((template
(or (ebox-surface--scroll-line-fragment-template line)
(error "Ebox retained scroll line lost fragment ownership"))))
(dolist (fragment template)
(funcall visit
(plist-get fragment :role-ids)
(+ offset (plist-get fragment :start))
(+ offset (plist-get fragment :end))))
(setq offset (+ offset (length line) 1))))))))
(defun ebox-surface--native-owned-ranges
(context leaf template state node-objects region-objects attach-p)
"Attach owner ranges from compact native fragment TEMPLATE."
@ -3832,11 +3872,17 @@ Adjacent ranges merge only when both their owner and opaque tags match."
(defun ebox-surface--materialized-fragment-ledger (state)
"Return STATE's paint ledger, expanding a native frame only on demand."
(let ((ledger (plist-get state :surface-fragments)))
(if-let* ((frame (and (listp ledger)
(plist-get ledger :native-frame))))
(ebox-native-reflow-frame-fragments frame)
ledger)))
(let* ((ledger (plist-get state :surface-fragments))
(frame (and (listp ledger) (plist-get ledger :native-frame)))
(lines (and (listp ledger) (plist-get ledger :scroll-lines))))
(cond
(frame (ebox-native-reflow-frame-fragments frame))
(lines
(ebox-surface--fragment-metadata
(or (ebox-surface--scroll-fragment-data lines "")
(error "Ebox retained scroll lines lost fragment ownership"))
state))
(t ledger))))
(defun ebox-surface--formatting-context-reflow-owned-ranges
(context leaf ranges state node-objects)
@ -3875,24 +3921,26 @@ all descendant ranges remain unchanged."
&optional transfer-text-p)
"Return one shared-text TP plan for OUTPUT and STATE.
NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(let* ((fragment-root
(let* ((scroll-lines
(and transfer-text-p
(plist-get state :retained-scroll-content-p)
(plist-get state :scroll-patch-lines)))
(fragment-root
(tp-object-ensure context surface-root
ebox-surface--fragments-key 'ebox/fragments))
(fragment-data
(or (and transfer-text-p
(plist-get state :retained-scroll-content-p)
(plist-get state :scroll-patch-fragment-data))
(and (eq output (plist-get state :native-render-output))
(plist-get state :native-render-fragment-template))
(and (eq (plist-get state :projection-kind)
'mixed-owner-reflow)
(plist-get state :mixed-owner-fragment-data))
(and (memq (plist-get state :projection-kind)
'(span-patch owner-scoped))
(plist-get state :span-patch-fragment-data))
(if (stringp output)
(ebox-surface--rendered-fragments output)
output)))
(unless scroll-lines
(or (and (eq output (plist-get state :native-render-output))
(plist-get state :native-render-fragment-template))
(and (eq (plist-get state :projection-kind)
'mixed-owner-reflow)
(plist-get state :mixed-owner-fragment-data))
(and (memq (plist-get state :projection-kind)
'(span-patch owner-scoped))
(plist-get state :span-patch-fragment-data))
(if (stringp output)
(ebox-surface--rendered-fragments output)
output))))
(rendered
(if (stringp output)
output
@ -3922,6 +3970,10 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(eq output (plist-get state :native-render-output))))
(owned-ranges
(cond
(scroll-lines
(ebox-surface--scroll-owned-ranges
context text-leaf scroll-lines state node-objects
region-objects attach-p))
((and native-template-p
(plist-get state :native-reuse-ownership-p))
(let ((ranges
@ -4009,6 +4061,8 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(cond
((plist-get state :native-render-frame)
(list :native-frame (plist-get state :native-render-frame)))
(scroll-lines
(list :scroll-lines (copy-sequence scroll-lines)))
((or (plist-get state :mixed-owner-content-p)
(plist-get state :span-patch-content-p))
;; Mixed projection already produced fresh offset-only
@ -4020,6 +4074,7 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(cl-remf state :mixed-owner-content-p)
(cl-remf state :span-patch-fragment-data)
(cl-remf state :span-patch-content-p)
(cl-remf state :scroll-patch-lines)
(let ((rendered-length (length rendered)))
(list plan rendered owned-ranges
(list (list :object text-leaf :start 0 :end rendered-length

22
ebox.el
View File

@ -954,10 +954,11 @@ several costly rows before yielding back to input."
This is the synchronous edge of the lazy scroll model: visible scrolling can
only replace cached line strings after those strings exist. When the user
reaches the current lazy prefix boundary, render at most one configured
interactive slice instead of pausing for an idle timer or materializing the
entire source. BUDGET-LINES caps how many new source lines this call may
request; callers that represent GUI animation ticks pass their tick size."
reaches the current lazy prefix boundary, render one configured interactive
slice plus the fixed lazy lookahead instead of pausing for an idle timer or
materializing the entire source. The lookahead amortizes composite source
rows across following wheel events. BUDGET-LINES caps the visible slice;
callers that represent GUI animation ticks pass their tick size."
(let* ((content-lines (plist-get state :content-lines))
(content-height (or (plist-get state :content-height) 0))
(current-lines (length content-lines))
@ -970,8 +971,7 @@ request; callers that represent GUI animation ticks pass their tick size."
(slice-lines (max 1 (or budget-lines ebox-scroll-step 1)))
(slice-end (+ current-lines slice-lines))
(required-lines (max current-lines
(min visible-end slice-end)))
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(min visible-end slice-end))))
(ebox--scroll-state-ensure-prefix-lines
region-id state required-lines t)))))
@ -3138,8 +3138,14 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(throw 'result (list :result 'pending)))
(when (and (> delta 0)
(or prefix-budget-lines
(and (ebox--scroll-sync-prefix-render-p)
(<= delta (ebox--scroll-prefetch-slice-lines)))))
;; A small foreground intent already has an explicit
;; frame budget. Extend exactly that bounded prefix
;; before publication so Ebox remains the sole owner of
;; the visible scroll coordinate. Deferring this case
;; leaves the intent residual to Emacs buffer scrolling;
;; the following Ebox commit then snaps WINDOW-START
;; back, producing a cold-cache flicker.
(<= delta (ebox--scroll-prefetch-slice-lines))))
(setq state
(ebox--scroll-ensure-bounded-prefix-for-offset
region-id state desired prefix-budget-lines)))

View File

@ -8309,8 +8309,8 @@
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-gui-cache-miss-returns-pending ()
"GUI scroll cache misses should not synchronously render missing content."
(ert-deftest ebox-lazy-scroll-gui-small-cache-miss-stays-in-ebox-coordinate ()
"A bounded GUI cache miss should extend and scroll in one Ebox commit."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-build
@ -8331,7 +8331,7 @@
(ebox-viewport-height 20)
(ebox-scroll-step 4)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-prefix-lookahead-lines 8)
(noninteractive nil))
(cl-letf (((symbol-function 'display-graphic-p)
(lambda (&optional _display) t)))
@ -8355,9 +8355,22 @@
(should (= boundary-offset
(ebox--scroll-region-by root-id
boundary-offset)))
(should (eq 'pending
(ebox--scroll-region-by root-id 4)))))
(should (= prefix-count 0))))
(should (= 4 (ebox--scroll-region-by root-id 4)))
(should (= (+ boundary-offset 4)
(plist-get (ebox--scroll-get-state root-id)
:scroll-offset)))
(should (= (+ initial-lines 12)
(length
(plist-get (ebox--scroll-get-state root-id)
:content-lines))))
(should
(= content-height
(length
(plist-get
(plist-get (ebox--buffer-render-state buffer)
:surface-fragments)
:scroll-lines))))))
(should (= prefix-count 1))))
(when root-id
(ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer))