diff --git a/ebox-surface.el b/ebox-surface.el index cd9fada..a8a707d 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -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 diff --git a/ebox.el b/ebox.el index 84a6164..ce4fbe4 100644 --- a/ebox.el +++ b/ebox.el @@ -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))) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 5f54c91..773ddb3 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -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))