fix: deliver viewport updates after window size hooks

This commit is contained in:
Kinneyzhang 2026-09-06 18:23:11 +08:00
parent 5143d17a56
commit dfa097e988
6 changed files with 329 additions and 43 deletions

View File

@ -117,6 +117,18 @@
:rollback cancel-candidate-work :rollback cancel-candidate-work
:rebuild-proof reschedule-from-committed-generation :rebuild-proof reschedule-from-committed-generation
:cleanup cancel-timers-jobs-and-gc-lease) :cleanup cancel-timers-jobs-and-gc-lease)
(:id viewport-event-authority
:storage (ebox--window-size-change-pending-frames
ebox--window-size-change-timer)
:current-contract frame-intent-queue-and-single-event-loop-task
:target-contract frame-intent-queue-and-single-event-loop-task
:category generation-bound-mutable-authority
:owner ebox-viewport
:mutation-api window-notify-schedule-and-drain
:generation-binding current-mounted-generation-sampled-at-delivery
:rollback publication-owned-rollback-and-requeue-unstarted-frames
:rebuild-proof subsequent-window-notification
:cleanup consume-frame-intents-and-release-dispatched-timer)
(:id incremental-batch-authority (:id incremental-batch-authority
:storage (ebox-incremental--batch-table) :storage (ebox-incremental--batch-table)
:current-contract process-buffer-keyed-open-batch-state :current-contract process-buffer-keyed-open-batch-state

View File

@ -1,13 +1,13 @@
;;; ebox-viewport.el --- Immediate Emacs viewport delivery -*- lexical-binding: t; -*- ;;; ebox-viewport.el --- Event-loop viewport delivery -*- lexical-binding: t; -*-
;;; Commentary: ;;; Commentary:
;; Emacs owns window events; Ebox owns the mounted viewport. This module is ;; Emacs owns window events; Ebox owns the mounted viewport. This module is
;; the single bridge between them. Size hooks only sample the latest visible ;; the single bridge between them. Size hooks record frame intents; one task
;; dimensions and synchronously publish one exact incremental viewport update. ;; samples current visible dimensions and publishes after the redisplay hook.
;; Emacs serializes the hook; Ebox's retained renderer bounds each transaction.
;;; Code: ;;; Code:
(require 'cl-lib)
(require 'ebox-surface) (require 'ebox-surface)
(declare-function ebox--buffer-render-state "ebox-incremental" (buffer)) (declare-function ebox--buffer-render-state "ebox-incremental" (buffer))
@ -15,23 +15,24 @@
"ebox" (buffer viewport-width &optional viewport-height)) "ebox" (buffer viewport-width &optional viewport-height))
(defvar ebox--window-size-change-in-progress nil (defvar ebox--window-size-change-in-progress nil
"Non-nil while Ebox is synchronizing visible window viewports. "Non-nil while Ebox is draining one batch of viewport notifications.")
External GUI events cannot interleave in Emacs's single thread. A nested call
can only originate from the current publication and therefore has no newer (defvar ebox--window-size-change-pending-frames nil
window geometry to publish.") "Frames awaiting viewport synchronization, deduplicated by EQ.
The queue holds no buffers, runtime roots or sampled dimensions.")
(defvar ebox--window-size-change-timer nil
"The single owned task scheduled to drain viewport notifications.")
(defun ebox-viewport-window-width (window) (defun ebox-viewport-window-width (window)
"Return Ebox's display-safe viewport width for live WINDOW." "Return Ebox's display-safe viewport width for live WINDOW."
(and (window-live-p window) (and (window-live-p window)
(ebox-surface--window-content-width window))) (ebox-surface--window-content-width window)))
(defun ebox--window-size-change (frame) (defun ebox--window-size-change-sync-frame (frame)
"Synchronize mounted Ebox buffers visible in FRAME immediately." "Synchronize current mounted Ebox viewports whose canonical window is in FRAME."
(when (and (frame-live-p frame) (when (frame-live-p frame)
(not noninteractive) (let ((seen (make-hash-table :test #'eq)))
(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)) (dolist (window (window-list frame 'no-minibuf))
(when (window-live-p window) (when (window-live-p window)
(let ((buffer (window-buffer window))) (let ((buffer (window-buffer window)))
@ -58,6 +59,51 @@ window geometry to publish.")
(ebox-rerender-buffer-with-context (ebox-rerender-buffer-with-context
buffer width height)))))))))))) buffer width height))))))))))))
(defun ebox--window-size-change-schedule ()
"Schedule one task for live pending frames outside the current batch."
(setq ebox--window-size-change-pending-frames
(cl-delete-if-not #'frame-live-p
ebox--window-size-change-pending-frames))
(when (and ebox--window-size-change-pending-frames
(not ebox--window-size-change-in-progress)
(not ebox--window-size-change-timer))
(setq ebox--window-size-change-timer
(run-at-time 0 nil #'ebox--window-size-change-drain))))
(defun ebox--window-size-change-drain ()
"Consume one frame batch, leaving nested notifications for a later task."
(unless ebox--window-size-change-in-progress
(let ((ebox--window-size-change-in-progress t)
(frames (nreverse ebox--window-size-change-pending-frames))
first-error)
(unwind-protect
(progn
(setq ebox--window-size-change-pending-frames nil
ebox--window-size-change-timer nil)
;; Timer dispatch inhibits quit by default. Enable C-g only after
;; cleanup is installed; TP retains its own final-accept protection.
(let ((inhibit-quit nil))
(while frames
(let ((frame (pop frames)))
(condition-case failure
(ebox--window-size-change-sync-frame frame)
(error (unless first-error (setq first-error failure))))))))
;; A quit may leave unstarted frames. Keep those intents, but do not
;; retry the frame whose publication already started and failed.
(dolist (frame frames)
(unless (memq frame ebox--window-size-change-pending-frames)
(push frame ebox--window-size-change-pending-frames)))
(let ((ebox--window-size-change-in-progress nil))
(ebox--window-size-change-schedule)))
(when first-error (signal (car first-error) (cdr first-error))))))
(defun ebox--window-size-change (frame)
"Record FRAME's latest viewport intent without rendering inside the hook."
(when (and (frame-live-p frame) (not noninteractive))
(unless (memq frame ebox--window-size-change-pending-frames)
(push frame ebox--window-size-change-pending-frames))
(ebox--window-size-change-schedule)))
(when (boundp 'window-size-change-functions) (when (boundp 'window-size-change-functions)
(add-hook 'window-size-change-functions #'ebox--window-size-change)) (add-hook 'window-size-change-functions #'ebox--window-size-change))

View File

@ -280,6 +280,18 @@
:authority ebox :lifetime process-by-buffer :authority ebox :lifetime process-by-buffer
:rollback tp-client-state-or-snapshot :rebuild render-or-commit :rollback tp-client-state-or-snapshot :rebuild render-or-commit
:cleanup buffer-kill-and-unmount) :cleanup buffer-kill-and-unmount)
(:id viewport/pending-frames
:storage (:global ebox--window-size-change-pending-frames)
:owner ebox-viewport :authority ebox :lifetime until-event-loop-drain
:rollback unstarted-frames-requeued-on-quit
:rebuild subsequent-window-size-notification
:cleanup drain-consumption-and-dead-frame-pruning)
(:id viewport/drain-timer
:storage (:global ebox--window-size-change-timer)
:owner ebox-viewport :authority ebox :lifetime until-event-loop-drain
:rollback not-publication-authority
:rebuild schedule-remaining-pending-frames
:cleanup timer-entry)
(:id buffer-runtime/node-table (:id buffer-runtime/node-table
:storage (:state-key :node-table) :storage (:state-key :node-table)
:proposed-category generation-fact :owner ebox-tree :authority ebox :proposed-category generation-fact :owner ebox-tree :authority ebox

View File

@ -231,6 +231,8 @@ scratch tables, which are intentionally outside the retained-store inventory."
(:global ebox--scroll-idle-prefetch-timers) (:global ebox--scroll-idle-prefetch-timers)
(:global ebox--runtime-prewarm-jobs) (:global ebox--runtime-prewarm-jobs)
(:global ebox--runtime-prewarm-timers) (:global ebox--runtime-prewarm-timers)
(:global ebox--window-size-change-pending-frames)
(:global ebox--window-size-change-timer)
(:global ebox--window-line-renderer-table) (:global ebox--window-line-renderer-table)
(:global ebox--window-line-prewarmer-table) (:global ebox--window-line-prewarmer-table)
(:global ebox-native-reflow--compile-property-template-ids) (:global ebox-native-reflow--compile-property-template-ids)

View File

@ -407,7 +407,8 @@ exclusion freshness, but only retained/container evidence is coverage-gated."
(ert-deftest ebox-state-contract-never-classifies-live-authority-as-cache () (ert-deftest ebox-state-contract-never-classifies-live-authority-as-cache ()
"Cleanup-sensitive handles retain generation-bound mutable authority." "Cleanup-sensitive handles retain generation-bound mutable authority."
(dolist (id '(scroll-runtime-authority native-runtime-authority (dolist (id '(scroll-runtime-authority native-runtime-authority
runtime-prewarm-authority incremental-batch-authority runtime-prewarm-authority viewport-event-authority
incremental-batch-authority
layout-context-port-authority identity-allocation-authority layout-context-port-authority identity-allocation-authority
buffer-surface-runtime-authority)) buffer-surface-runtime-authority))
(let ((record (ebox-state-contract-record id))) (let ((record (ebox-state-contract-record id)))

View File

@ -440,8 +440,71 @@
(when (get-buffer " *other-window*") (when (get-buffer " *other-window*")
(kill-buffer " *other-window*"))))) (kill-buffer " *other-window*")))))
(defvar ebox--window-size-change-pending-frames)
(defvar ebox--window-size-change-timer)
(defmacro ebox-surface-test--with-viewport-task (&rest body)
"Run BODY with an isolated viewport task queue and DISPATCH callback."
(declare (indent 0))
`(let ((ebox--window-size-change-pending-frames nil)
(ebox--window-size-change-timer nil)
(ebox--window-size-change-in-progress nil)
(native-comp-enable-subr-trampolines nil)
scheduled)
(cl-letf (((symbol-function 'run-at-time)
(lambda (delay repeat function &rest arguments)
(should (eql delay 0))
(should-not repeat)
(let ((task (list function arguments)))
(setq scheduled (append scheduled (list task)))
task)))
(noninteractive nil))
(cl-labels ((dispatch ()
(should (= (length scheduled) 1))
(let ((task (pop scheduled))
;; Match the event loop's timer-handler default.
(inhibit-quit t))
(apply (car task) (cadr task)))))
,@body))))
(ert-deftest ebox-window-size-change-defers-and-resamples-latest-geometry ()
"The hook records one frame intent and leaves geometry/render work to dispatch."
(let* ((buffer (generate-new-buffer " *ebox-viewport-event*"))
(window (selected-window))
(old-buffer (window-buffer window))
(width 240) (height 20) (samples 0) calls)
(unwind-protect
(progn
(set-window-buffer window buffer)
(ebox-surface-test--with-viewport-task
(cl-letf (((symbol-function 'ebox--buffer-render-state)
(lambda (_) '(:viewport-width 100 :viewport-height 10)))
((symbol-function 'ebox-surface-buffer-mounted-p)
(lambda (_) t))
((symbol-function 'ebox-surface--window-content-width)
(lambda (_) (cl-incf samples) width))
((symbol-function 'window-body-height)
(lambda (&rest _) height))
((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (&rest args) (push args calls))))
(dotimes (_ 20) (ebox--window-size-change (selected-frame)))
(should-not calls)
(should (= samples 0))
(should (= (length scheduled) 1))
(should (equal ebox--window-size-change-pending-frames
(list (selected-frame))))
(setq width 430 height 31)
(dispatch)
(should (equal calls (list (list buffer 430 31))))
(should (= samples 1))
(should-not scheduled)
(should-not ebox--window-size-change-pending-frames)
(should-not ebox--window-size-change-timer))))
(set-window-buffer window old-buffer)
(kill-buffer buffer))))
(ert-deftest ebox-window-size-change-publishes-every-visible-viewport () (ert-deftest ebox-window-size-change-publishes-every-visible-viewport ()
"Continuous frame changes publish each sampled viewport immediately." "Separate event-loop batches publish each current visible viewport."
(let* ((buffer (generate-new-buffer " *ebox-window-size-change*")) (let* ((buffer (generate-new-buffer " *ebox-window-size-change*"))
(window (selected-window)) (window (selected-window))
(old-buffer (window-buffer window)) (old-buffer (window-buffer window))
@ -451,7 +514,8 @@
(unwind-protect (unwind-protect
(progn (progn
(set-window-buffer window buffer) (set-window-buffer window buffer)
(cl-letf (((symbol-function 'ebox--buffer-render-state) (ebox-surface-test--with-viewport-task
(cl-letf (((symbol-function 'ebox--buffer-render-state)
(lambda (_buffer) (lambda (_buffer)
'(:viewport-width 100 :viewport-height 10))) '(:viewport-width 100 :viewport-height 10)))
((symbol-function 'ebox-surface-buffer-mounted-p) ((symbol-function 'ebox-surface-buffer-mounted-p)
@ -460,13 +524,10 @@
(lambda (_window) (pop sampled-widths))) (lambda (_window) (pop sampled-widths)))
((symbol-function 'ebox-rerender-buffer-with-context) ((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (target width height) (lambda (target width height)
(push (list target width height) calls))) (push (list target width height) calls))))
((symbol-function 'run-at-time)
(lambda (&rest _)
(ert-fail "viewport delivery must not schedule a timer")))
(noninteractive nil))
(dotimes (_index 20) (dotimes (_index 20)
(ebox--window-size-change (selected-frame))) (ebox--window-size-change (selected-frame))
(dispatch))
(should (= (length calls) 20)) (should (= (length calls) 20))
(should (should
(equal (mapcar #'cadr (nreverse (copy-sequence calls))) (equal (mapcar #'cadr (nreverse (copy-sequence calls)))
@ -474,12 +535,12 @@
(should (cl-every (lambda (call) (should (cl-every (lambda (call)
(and (eq (car call) buffer) (and (eq (car call) buffer)
(= (nth 2 call) sampled-height))) (= (nth 2 call) sampled-height)))
calls)))) calls)))))
(set-window-buffer window old-buffer) (set-window-buffer window old-buffer)
(kill-buffer buffer)))) (kill-buffer buffer))))
(ert-deftest ebox-window-size-change-rejects-reentrant-publication () (ert-deftest ebox-window-size-change-defers-reentrant-notifications ()
"A viewport commit cannot recursively enter the global size hook." "Nested notifications become a later batch without recursive publication."
(let* ((buffer (generate-new-buffer " *ebox-reentrant-window-size*")) (let* ((buffer (generate-new-buffer " *ebox-reentrant-window-size*"))
(window (selected-window)) (window (selected-window))
(old-buffer (window-buffer window)) (old-buffer (window-buffer window))
@ -488,7 +549,8 @@
(unwind-protect (unwind-protect
(progn (progn
(set-window-buffer window buffer) (set-window-buffer window buffer)
(cl-letf (((symbol-function 'ebox--buffer-render-state) (ebox-surface-test--with-viewport-task
(cl-letf (((symbol-function 'ebox--buffer-render-state)
(lambda (_buffer) (lambda (_buffer)
'(:viewport-width 100 :viewport-height 10))) '(:viewport-width 100 :viewport-height 10)))
((symbol-function 'ebox-surface-buffer-mounted-p) ((symbol-function 'ebox-surface-buffer-mounted-p)
@ -500,11 +562,18 @@
((symbol-function 'ebox-rerender-buffer-with-context) ((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (&rest arguments) (lambda (&rest arguments)
(push arguments calls) (push arguments calls)
(ebox--window-size-change (selected-frame)))) (when (= (length calls) 1)
(noninteractive nil)) (ebox--window-size-change (selected-frame))))))
(ebox--window-size-change (selected-frame))) (ebox--window-size-change (selected-frame))
(should (= (length calls) 1)) (dispatch)
(should (equal sampled-widths '(440)))) (should (= (length calls) 1))
(should (equal sampled-widths '(440)))
(should-not ebox--window-size-change-in-progress)
(dispatch)
(should (= (length calls) 2))
(should-not sampled-widths)
(should-not scheduled)
(should-not ebox--window-size-change-timer))))
(set-window-buffer window old-buffer) (set-window-buffer window old-buffer)
(kill-buffer buffer)))) (kill-buffer buffer))))
@ -518,7 +587,8 @@
(unwind-protect (unwind-protect
(progn (progn
(set-window-buffer window buffer) (set-window-buffer window buffer)
(cl-letf (((symbol-function 'ebox--buffer-render-state) (ebox-surface-test--with-viewport-task
(cl-letf (((symbol-function 'ebox--buffer-render-state)
(lambda (_buffer) (lambda (_buffer)
'(:viewport-width 100 :viewport-height 10))) '(:viewport-width 100 :viewport-height 10)))
((symbol-function 'ebox-surface-buffer-mounted-p) ((symbol-function 'ebox-surface-buffer-mounted-p)
@ -530,12 +600,15 @@
((symbol-function 'ebox-rerender-buffer-with-context) ((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (&rest arguments) (lambda (&rest arguments)
(push arguments calls) (push arguments calls)
(when fail (error "viewport update failed")))) (when fail (error "viewport update failed")))))
(noninteractive nil)) (ebox--window-size-change (selected-frame))
(should-error (ebox--window-size-change (selected-frame))) (should-error (dispatch))
(should-not ebox--window-size-change-in-progress) (should-not ebox--window-size-change-in-progress)
(should-not ebox--window-size-change-timer)
(should-not scheduled)
(setq fail nil) (setq fail nil)
(ebox--window-size-change (selected-frame))) (ebox--window-size-change (selected-frame))
(dispatch)))
(should (= (length calls) 2))) (should (= (length calls) 2)))
(set-window-buffer window old-buffer) (set-window-buffer window old-buffer)
(kill-buffer buffer)))) (kill-buffer buffer))))
@ -543,15 +616,17 @@
(ert-deftest ebox-window-size-change-follows-canonical-display-frame () (ert-deftest ebox-window-size-change-follows-canonical-display-frame ()
"A stale frame cannot overwrite a surface owned by another live frame." "A stale frame cannot overwrite a surface owned by another live frame."
(let ((buffer (generate-new-buffer " *ebox-canonical-frame*")) (let ((buffer (generate-new-buffer " *ebox-canonical-frame*"))
(canonical-frame 'stale-frame)
calls) calls)
(unwind-protect (unwind-protect
(cl-letf (((symbol-function 'frame-live-p) (lambda (_frame) t)) (ebox-surface-test--with-viewport-task
(cl-letf (((symbol-function 'frame-live-p) (lambda (_frame) t))
((symbol-function 'window-list) ((symbol-function 'window-list)
(lambda (&rest _) '(event-window))) (lambda (&rest _) '(event-window)))
((symbol-function 'window-live-p) (lambda (_window) t)) ((symbol-function 'window-live-p) (lambda (_window) t))
((symbol-function 'window-buffer) (lambda (_window) buffer)) ((symbol-function 'window-buffer) (lambda (_window) buffer))
((symbol-function 'window-frame) ((symbol-function 'window-frame)
(lambda (_window) 'canonical-frame)) (lambda (_window) canonical-frame))
((symbol-function 'ebox-surface--buffer-display-window) ((symbol-function 'ebox-surface--buffer-display-window)
(lambda (_buffer) 'canonical-window)) (lambda (_buffer) 'canonical-window))
((symbol-function 'ebox-surface-buffer-mounted-p) ((symbol-function 'ebox-surface-buffer-mounted-p)
@ -564,14 +639,152 @@
((symbol-function 'window-body-height) ((symbol-function 'window-body-height)
(lambda (&rest _) 20)) (lambda (&rest _) 20))
((symbol-function 'ebox-rerender-buffer-with-context) ((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (&rest arguments) (push arguments calls))) (lambda (&rest arguments) (push arguments calls))))
(noninteractive nil))
(ebox--window-size-change 'stale-frame) (ebox--window-size-change 'stale-frame)
(setq canonical-frame 'canonical-frame)
(dispatch)
(should-not calls) (should-not calls)
(ebox--window-size-change 'canonical-frame) (ebox--window-size-change 'canonical-frame)
(should (equal calls (list (list buffer 420 20))))) (dispatch)
(should (equal calls (list (list buffer 420 20))))))
(kill-buffer buffer)))) (kill-buffer buffer))))
(ert-deftest ebox-window-size-change-discards-obsolete-intents ()
"Dead frames, killed buffers and unmounted surfaces do no deferred work."
(dolist (retired '(frame buffer surface))
(let ((buffer (generate-new-buffer " *ebox-retired-viewport*"))
(live t) (mounted t) calls)
(unwind-protect
(ebox-surface-test--with-viewport-task
(cl-letf (((symbol-function 'frame-live-p) (lambda (_) live)))
(ebox--window-size-change 'frame))
;; Real buffer disposal must see Emacs's real window functions.
;; Install the synthetic display topology only for dispatch.
(pcase retired
('frame (setq live nil))
('buffer (kill-buffer buffer))
('surface (setq mounted nil)))
(cl-letf (((symbol-function 'frame-live-p) (lambda (_) live))
((symbol-function 'window-list) (lambda (&rest _) '(window)))
((symbol-function 'window-live-p) (lambda (window) (eq window 'window)))
((symbol-function 'window-buffer) (lambda (_) buffer))
((symbol-function 'window-frame) (lambda (_) 'frame))
((symbol-function 'ebox-surface--buffer-display-window)
(lambda (_) 'window))
((symbol-function 'ebox-surface-buffer-mounted-p)
(lambda (_) mounted))
((symbol-function 'ebox--buffer-render-state)
(lambda (_) '(:viewport-width 100 :viewport-height 10)))
((symbol-function 'ebox-surface--window-content-width)
(lambda (_) 420))
((symbol-function 'window-body-height) (lambda (&rest _) 20))
((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (&rest args) (push args calls))))
(dispatch)
(should-not calls)
(should-not scheduled)
(should-not ebox--window-size-change-timer)
(should-not ebox--window-size-change-pending-frames)))
(when (buffer-live-p buffer) (kill-buffer buffer))))))
(ert-deftest ebox-window-size-change-error-preserves-other-frame-work ()
"One frame failure cannot drop other frames or retry itself automatically."
(ebox-surface-test--with-viewport-task
(let (calls)
(cl-letf (((symbol-function 'frame-live-p) (lambda (_) t))
((symbol-function 'ebox--window-size-change-sync-frame)
(lambda (frame)
(should-not inhibit-quit)
(push frame calls)
(when (eq frame 'first)
(ebox--window-size-change 'nested)
(should-not scheduled)
(error "First frame failed")))))
(ebox--window-size-change 'first)
(ebox--window-size-change 'second)
(should-error (dispatch))
(should (equal (reverse calls) '(first second)))
(should-not ebox--window-size-change-in-progress)
(should (= (length scheduled) 1))
(dispatch)
(should (equal (reverse calls) '(first second nested)))
(should-not scheduled)
(should-not ebox--window-size-change-timer)
(should-not ebox--window-size-change-pending-frames)))))
(ert-deftest ebox-window-size-change-quit-restores-unstarted-work ()
"Quit escapes the task while cleanup keeps only unstarted or new intents."
(ebox-surface-test--with-viewport-task
(let (calls aborted)
(cl-letf (((symbol-function 'frame-live-p) (lambda (_) t))
((symbol-function 'ebox--window-size-change-sync-frame)
(lambda (frame)
(should-not inhibit-quit)
(push frame calls)
(when (eq frame 'first)
(ebox--window-size-change 'nested)
(signal 'quit nil)))))
(ebox--window-size-change 'first)
(ebox--window-size-change 'second)
(condition-case nil (dispatch) (quit (setq aborted t)))
(should aborted)
(should (equal calls '(first)))
(should-not ebox--window-size-change-in-progress)
(should (= (length scheduled) 1))
(dispatch)
(should (= (length calls) 3))
(should (memq 'second calls))
(should (memq 'nested calls))
(should-not scheduled)
(should-not ebox--window-size-change-timer)
(should-not ebox--window-size-change-pending-frames)))))
(ert-deftest ebox-window-size-change-preserves-synchronous-public-update ()
"A direct publication completes synchronously and makes a queued intent a no-op."
(let* ((buffer (generate-new-buffer " *ebox-direct-viewport*"))
(window (selected-window))
(old-buffer (window-buffer window))
(ebox-viewport-width 180) (ebox-viewport-height 6)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(map (make-sparse-keymap))
(text (propertize "Open" 'keymap map 'help-echo "Open item"))
(input (ebox-build `(box :width (viewport) :height (viewport-height)
(text ,text)))))
(define-key map [mouse-1] #'ignore)
(unwind-protect
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(set-window-buffer window buffer)
(ebox-render-to-buffer buffer input)
(ebox-surface-test--with-viewport-task
(cl-letf (((symbol-function 'ebox-surface--window-content-width)
(lambda (_) 420))
((symbol-function 'window-body-height) (lambda (&rest _) 20)))
(let* ((surface (plist-get (ebox--buffer-render-state buffer) :surface))
(revision (tp-surface-revision surface)))
(ebox--window-size-change (selected-frame))
;; Only notification requires the simulated interactive hook.
;; Run the public API in this test process's batch context.
(let ((noninteractive t))
(ebox-rerender-buffer-with-context buffer 420 20))
(should (= (tp-surface-revision surface) (1+ revision)))
(should (= (plist-get (ebox--buffer-render-state buffer)
:viewport-width) 420))
(should (= (plist-get (ebox--buffer-render-state buffer)
:viewport-height) 20))
(let ((output (with-current-buffer buffer (buffer-string))))
(dispatch)
(should (= (tp-surface-revision surface) (1+ revision)))
(should (equal-including-properties
output (with-current-buffer buffer (buffer-string))))
(should (eq (lookup-key (get-text-property 0 'keymap output)
[mouse-1]) #'ignore))
(should (equal (get-text-property 0 'help-echo output) "Open item")))
(should-not scheduled)))))
(set-window-buffer window old-buffer)
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun ebox-surface-test--hash-fingerprint (table) (defun ebox-surface-test--hash-fingerprint (table)
"Return a stable content fingerprint for hash TABLE. "Return a stable content fingerprint for hash TABLE.
The fingerprint checks entries rather than only table identity, so a failed The fingerprint checks entries rather than only table identity, so a failed