ebox/ebox-layer.el
Kinneyzhang 01b1a86bb3
Some checks are pending
CI / test (29.1) (push) Waiting to run
CI / test (30.2) (push) Waiting to run
CI / native-build (macos-latest) (push) Waiting to run
CI / native-build (ubuntu-latest) (push) Waiting to run
CI / native-build (windows-latest) (push) Waiting to run
CI / native-msrv (macos-latest) (push) Waiting to run
CI / native-msrv (ubuntu-latest) (push) Waiting to run
CI / native-msrv (windows-latest) (push) Waiting to run
Optimize incremental overlay layer recomposition
2026-09-11 13:31:17 +08:00

496 lines
25 KiB
EmacsLisp

;;; ebox-layer.el --- Retained box placement and text composition -*- lexical-binding: t; -*-
;;; Commentary:
;; Compose visible pieces without changing the logical tree. Horizontal
;; coordinates are pixels; vertical coordinates share the host's text rows.
;; This module never writes a buffer or owns application state.
;;; Code:
(require 'cl-lib)
(require 'ebox-tree)
(require 'ebox-composite)
(declare-function ebox-get "ebox" (box property))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox--substring-pixel-width "ebox-measure" (string start end))
(declare-function ebox--string-pixel-width "ebox-measure" (string))
(declare-function ebox-string-lines "ebox" (string))
(declare-function ebox-lines-join "ebox" (lines))
(declare-function ebox--render-with-cache "ebox" (node &optional width probe))
(declare-function ebox--call-with-box-content-context "ebox-layout" (box function))
(declare-function ebox--wrapper-content-viewport-pixel "ebox-layout" (box))
(declare-function ebox--content-height "ebox-layout" (box height &optional rendered))
(declare-function ebox--size-pixels "ebox-layout" (value &optional box axis percent-base))
(declare-function ebox--size-line-height "ebox-layout" (&optional box))
(declare-function ebox--record-rendered-uniform-width "ebox-layout" (rendered width))
(declare-function ebox--record-rendered-intrinsic-size "ebox-layout" (rendered width height))
(declare-function ebox-pixel-space "ebox" (pixels))
(defvar ebox-region-types)
(defvar ebox--intrinsic-layout-measurement)
(defvar ebox--render-source-index)
(defvar ebox-layer--render-root nil
"Root of this spatial materialization, never a live buffer root.")
(defvar ebox-layer--portals nil
"Candidate-local absolute nodes awaiting root composition.")
(defvar ebox-layer--piece-width-cache nil
"Original row prefix widths scoped to one host composition.
Keys are source strings by identity. Never retained between renders.")
(defvar ebox-layer--active-p 'unknown
"Whether the current candidate contains layered layout.
Retained publication supplies a known boolean; isolated rendering resolves
the fact once for its spatial root.")
(defvar ebox-layer--previous-composition-cache nil
"Read-only retained host composition ledger for the current candidate.
The ledger is an opaque hash table owned by the Surface runtime. Entries are
keyed by host identity and composition phase and contain immutable row
signatures plus their already composed rows. A missing ledger disables reuse.")
(defvar ebox-layer--composition-cache nil
"Candidate-local host composition ledger populated during rendering.
Callers should bind this to a fresh hash table for one candidate; this module
never mutates `ebox-layer--previous-composition-cache'. Use an `equal' hash
table because ledger keys are freshly allocated HOST/PHASE cons cells.")
(defvar ebox-layer--composition-context nil
"Immutable context token for host composition reuse.
The Surface runtime should include every display and allocation fact that can
change pixel measurement. Reuse is disabled when this token is nil.")
(defvar ebox-layer--composition-phase 'final
"Composition phase used as the default host ledger key.
Anchor prepasses and final paints must use distinct phase values.")
(defun ebox-layer-active-p (&optional node)
"Return the current candidate's layer capability without repeated scans."
(if (eq ebox-layer--active-p 'unknown)
(if ebox-layer--render-root
(setq ebox-layer--active-p
(ebox-layer-subtree-p ebox-layer--render-root))
(ebox-layer-subtree-p node))
ebox-layer--active-p))
(defun ebox-layer-positioned-p (node)
"Return non-nil when NODE opts into positioned box painting."
(memq (plist-get node :position) '(relative absolute)))
(defun ebox-layer-host-p (node)
"Return non-nil when NODE composes positioned direct children."
(and (eq (plist-get node :ebox-kind) 'box)
(cl-some #'ebox-layer-positioned-p
(ebox-tree-layout-children node))))
(defun ebox-layer-subtree-p (node)
"Return non-nil when NODE's logical subtree contains positioned painting."
(and (listp node)
(or (ebox-layer-host-p node)
(ebox-layer-positioned-p node)
(cl-some #'ebox-layer-subtree-p (ebox-tree-node-children node)))))
(defun ebox-layer-portals-p (node)
"Return non-nil when NODE's subtree projects content to its render root."
(and (listp node)
(or (eq (plist-get node :layer) 'root)
(cl-some #'ebox-layer-portals-p (ebox-tree-node-children node)))))
(defun ebox-layer-visible-portals-p (node)
"Return non-nil when NODE can paint an absolute root projection.
Hidden owners suppress their complete logical subtree. Keep this visible
dependency separate from structural portal detection: hidden portals remain
in the retained input and can become visible in a later candidate."
(and (listp node)
(not (eq (plist-get node :visibility) 'hidden))
(or (and (eq (plist-get node :position) 'absolute)
(eq (plist-get node :layer) 'root))
(cl-some #'ebox-layer-visible-portals-p
(ebox-tree-node-children node)))))
(defun ebox-layer--owned-p (line position region)
"Whether LINE at POSITION visibly belongs to REGION, excluding margins."
(and (not (cl-some (lambda (role)
(equal region (get-text-property position role line)))
'(ebox-ml ebox-mr ebox-mt ebox-mb)))
(or (member region (get-text-property position 'ebox-content-owners line))
(equal region (get-text-property position 'ebox-content-owner line))
(cl-some (lambda (role)
(equal region (get-text-property position (cdr role) line)))
ebox-region-types))))
(defun ebox-layer-cached-root-scroll-p (root state)
"Return non-nil when ROOT can slice STATE's complete composed content.
This proves only the cached content geometry. Callers must separately prove
a pure root scroll transaction with unchanged source and display context.
Root projections and anchored provider chains are already composed in this
content coordinate system, before the root wrapper applies its scroll offset.
Nested scroll changes and source updates cannot use this root-only proof."
(and root state
(equal (plist-get root :node-id)
(plist-get (plist-get state :box) :node-id))
(eq (ebox-get root :overflow) 'scroll)
(plist-get state :content-lines)
(or (plist-get state :content-lines-complete-p)
(and (not (plist-get state :render-content-prefix))
(not (plist-get state :materialize-content-lines))))
(not (plist-get state :lazy-scroll-prefix-dirty))
(not (plist-get state :lazy-scroll-window-refresh-required))))
(defun ebox-layer--prefix-width (line end)
"Return LINE's original prefix width at END within this composition."
(if (zerop end) 0
(let ((widths (or (gethash line ebox-layer--piece-width-cache)
(puthash line (make-hash-table :test #'eql)
ebox-layer--piece-width-cache))))
(or (gethash end widths)
(puthash end (ebox--substring-pixel-width line 0 end) widths)))))
(defun ebox-layer--pieces (lines node)
"Return NODE's visible source intervals in LINES, before positioning."
(let ((region (ebox--ensure-region-id node))
(ebox-layer--piece-width-cache
(or ebox-layer--piece-width-cache (make-hash-table :test #'eq)))
pieces)
(cl-loop for line in lines for row from 0 do
(let ((start 0) (limit (length line)))
(while (< start limit)
(let ((end (next-property-change start line limit)))
(when (ebox-layer--owned-p line start region)
(let ((x (ebox-layer--prefix-width line start))
(right (ebox-layer--prefix-width line end)))
(when (> right x)
;; Keep the full row as source so shaping and shared
;; display objects are measured in their own context.
(push (list :line line :start x :end right :y row)
pieces))))
(setq start end)))))
;; Property boundaries must not become artificial glyph clipping edges.
(let (merged)
(dolist (piece (nreverse pieces))
(let ((last (car merged)))
(if (and last (= (plist-get last :y) (plist-get piece :y))
(= (plist-get last :end) (plist-get piece :start)))
(plist-put last :end (plist-get piece :end))
(push piece merged))))
(nreverse merged))))
(defun ebox-layer--bounds (pieces)
"Return (X Y WIDTH HEIGHT) covering nonempty PIECES, or nil."
(when pieces
(let ((left (apply #'min (mapcar (lambda (p) (plist-get p :start)) pieces)))
(right (apply #'max (mapcar (lambda (p) (plist-get p :end)) pieces)))
(top (apply #'min (mapcar (lambda (p) (plist-get p :y)) pieces)))
(bottom (apply #'max (mapcar (lambda (p) (plist-get p :y)) pieces))))
(list left top (- right left) (1+ (- bottom top))))))
(defun ebox-layer--offset (node property host extent)
"Resolve NODE's PROPERTY against HOST and containing axis EXTENT."
(let* ((vertical (eq property :top))
(value (or (plist-get node property) (if vertical '(lh 0) '(px 0))))
(pixels (ebox--size-pixels value host (if vertical 'height 'width)
(if vertical
(* extent (ebox--size-line-height host))
extent))))
(unless (numberp pixels)
(error "Ebox layer offset has no definite containing size: %S" value))
(if vertical (floor (/ pixels (float (ebox--size-line-height host)))) pixels)))
(defun ebox-layer--validate-child (node host)
"Check text backend placement contracts for NODE inside HOST."
(ebox-layer-validate-node node)
(when (ebox-layer-positioned-p node)
(cl-labels ((check (child)
(unless (= (ebox--size-line-height child)
(ebox--size-line-height host))
(error "Ebox positioned boxes must share their host's line height"))
(mapc #'check (ebox-tree-node-children child))))
(check node))))
(defun ebox-layer-validate-node (node)
"Validate NODE's placement combinations independently of visibility."
(when (and (or (plist-get node :anchor)
(eq (plist-get node :layer) 'root))
(not (eq (plist-get node :position) 'absolute)))
(error "Ebox :anchor and :layer root require :position absolute")))
(defun ebox-layer--ordered-records (host records)
"Sort candidate RECORDS by layer and HOST logical document order."
(let ((order (make-hash-table :test #'eq)) (index 0))
(cl-labels ((visit (node)
(puthash node (cl-incf index) order)
(mapc #'visit (ebox-tree-node-children node))))
(visit host))
(cl-stable-sort
(copy-sequence records)
(lambda (a b)
(let* ((an (plist-get a :node)) (bn (plist-get b :node))
(az (or (plist-get an :z-index) 0))
(bz (or (plist-get bn :z-index) 0)))
(cond
((/= az bz) (< az bz))
((not (eq (and (ebox-layer-positioned-p an) t)
(and (ebox-layer-positioned-p bn) t)))
(not (ebox-layer-positioned-p an)))
(t (< (gethash an order 0) (gethash bn order 0)))))))))
(defun ebox-layer--stable-node-id (node)
"Return NODE's retained identity, or nil when it cannot be proven stable."
(or (plist-get node :node-id)
(and ebox--render-source-index
(ebox-tree-node-id ebox--render-source-index node))))
(defun ebox-layer--record-signature (record)
"Return immutable comparison facts for one paint RECORD.
Do not retain NODE itself: copied candidate nodes can have mutable transient
attachments. The sliced text is compared with its complete text properties,
which captures display geometry, paint and interaction values."
(let ((node (plist-get record :node)))
(list :node-id (ebox-layer--stable-node-id node)
:x (plist-get record :x)
:y (plist-get record :y)
:z-index (or (plist-get node :z-index) 0)
:positioned (and (ebox-layer-positioned-p node) t)
:text (plist-get record :text))))
(defun ebox-layer--compose-cache-key (host phase)
"Return the retained-ledger key for HOST and composition PHASE.
Use the retained node id so copied candidate nodes can reuse their prior
host's rows; an unidentifiable host disables the cache."
(when-let* ((id (ebox-layer--stable-node-id host)))
(cons id phase)))
(defun ebox-layer--compose-rows (host records width height &optional phase)
"Compose HOST's visible RECORDS in a WIDTH by HEIGHT content canvas.
When the caller binds the retained and candidate ledgers and an immutable
composition context, rows whose ordered record signatures are unchanged are
returned directly without calling `ebox-composite-line'. PHASE separates
anchor prepasses from final composition so neither can reuse the other."
(let* ((phase (or phase ebox-layer--composition-phase))
(ordered (ebox-layer--ordered-records host records))
(rows (make-vector height nil))
(signatures (make-vector height nil))
(key (ebox-layer--compose-cache-key host phase))
(previous (and ebox-layer--composition-context
(hash-table-p ebox-layer--previous-composition-cache)
(gethash key ebox-layer--previous-composition-cache)))
(old-context (and previous (plist-get previous :context)))
(old-signatures (and previous (plist-get previous :signatures)))
(old-rows (and previous (plist-get previous :rows)))
(cacheable-p (and key ebox-layer--composition-context
(hash-table-p ebox-layer--composition-cache))))
(dolist (record ordered)
(let ((row (plist-get record :y)))
(when (and (integerp row) (>= row 0) (< row height))
(push record (aref rows row)))))
(dotimes (row height)
(let* ((placements (nreverse (aref rows row)))
(signature (mapcar #'ebox-layer--record-signature placements))
(reusable-p
(and cacheable-p previous
(equal old-context ebox-layer--composition-context)
(= width (plist-get previous :width))
(= height (plist-get previous :height))
(vectorp old-signatures) (vectorp old-rows)
(= (length old-rows) height)
(cl-every (lambda (entry)
(plist-get entry :node-id))
signature)
(= (length old-signatures) height)
(equal-including-properties
signature (aref old-signatures row)))))
(aset signatures row signature)
(aset rows row
(if reusable-p
(aref old-rows row)
(ebox-composite-line "" placements width)))))
(when cacheable-p
(puthash key (list :context ebox-layer--composition-context
:width width :height height
:signatures signatures :rows rows)
ebox-layer--composition-cache))
(append rows nil)))
(defun ebox-layer--anchor-node (host id)
"Find one logical anchor ID under HOST, rejecting ambiguous IDs."
(let ((name (format "%s" id)) matches)
(cl-labels ((visit (node)
(when (equal name (ebox-tree-node-id ebox--render-source-index node))
(push node matches))
(mapc #'visit (ebox-tree-node-children node))))
(visit host))
(when (cdr matches) (error "Ebox layer anchor is ambiguous: %s" name))
(car matches)))
(defun ebox-layer--absolute-origin (node host bounds width height layer-width layer-height)
"Resolve NODE's origin in HOST, or nil for an invisible anchor.
BOUNDS is the already resolved anchor rectangle, or nil.
WIDTH and HEIGHT bound the host; LAYER-WIDTH and LAYER-HEIGHT bound the panel."
(let ((x (ebox-layer--offset node :left host width))
(y (ebox-layer--offset node :top host height))
(anchor (plist-get node :anchor)))
(if (not anchor) (list x y)
(when bounds
(let* ((placement (or (plist-get node :placement) 'bottom-start))
(above (memq placement '(top-start top-end)))
(left (+ (car bounds) x
(if (memq placement '(bottom-end top-end))
(- (nth 2 bounds) layer-width) 0)))
(top (+ y (nth 1 bounds)
(if above (- layer-height) (nth 3 bounds))))
(other (+ y (nth 1 bounds)
(if above (nth 3 bounds) (- layer-height)))))
(when (and (or (< top 0) (> (+ top layer-height) height))
(>= other 0) (<= (+ other layer-height) height))
(setq top other))
(list (max 0 (min left (max 0 (- width layer-width))))
(max 0 (min top (max 0 (- height layer-height))))))))))
(defun ebox-layer--visible-bounds (bounds origin width height)
"Translate BOUNDS by ORIGIN and intersect the WIDTH by HEIGHT canvas."
(when (and bounds origin)
(let* ((x (+ (car bounds) (car origin)))
(y (+ (cadr bounds) (cadr origin)))
(left (max 0 x)) (top (max 0 y))
(right (min width (+ x (nth 2 bounds))))
(bottom (min height (+ y (nth 3 bounds)))))
(when (and (> right left) (> bottom top))
(list left top (- right left) (- bottom top))))))
(defun ebox-layer--absolute-records (host nodes base width height)
"Resolve HOST's absolute NODES and return opaque paint records.
BASE supplies normal-flow anchor fragments. Anchor dependencies are resolved
before painting, so source order and z order never select the geometry.
Missing or clipped anchors suppress their dependents; cycles signal before
publication. Nested root projections join this same candidate-local pass."
(let ((panels (make-hash-table :test #'eq))
(parents (make-hash-table :test #'eq))
(status (make-hash-table :test #'eq))
(queue (copy-sequence nodes)) order records)
(cl-labels
((index (node)
(dolist (child (ebox-tree-node-children node))
(puthash child node parents)
(index child)))
(panel-owner (node)
(while (and node (not (gethash node panels)))
(setq node (gethash node parents)))
node)
(resolve (node)
(pcase (gethash node status)
('visiting (error "Ebox layer anchor dependency is cyclic"))
('done (plist-get (gethash node panels) :origin))
(_
(puthash node 'visiting status)
(let* ((panel (gethash node panels))
(anchor (plist-get node :anchor))
(target (and anchor (ebox-layer--anchor-node host anchor)))
(provider (and target (panel-owner target)))
(bounds
(when (and target (not (eq (plist-get target :visibility) 'hidden)))
(if provider
(let ((origin (resolve provider)))
(ebox-layer--visible-bounds
(ebox-layer--bounds
(ebox-layer--pieces
(plist-get (gethash provider panels) :lines) target))
origin width height))
(ebox-layer--bounds (ebox-layer--pieces base target)))))
(origin
(unless (eq (plist-get node :visibility) 'hidden)
(ebox-layer--absolute-origin
node host bounds width height
(plist-get panel :width) (length (plist-get panel :lines))))))
(plist-put panel :origin origin)
(puthash node 'done status)
origin)))))
(index host)
(while queue
(let ((node (pop queue)))
(unless (gethash node panels)
(ebox-layer--validate-child node host)
(let* ((text
(unless (eq (plist-get node :visibility) 'hidden)
(ebox--call-with-box-content-context
host (lambda () (ebox--render-with-cache node)))))
(lines (and text (ebox-string-lines text))))
(puthash node
(list :lines lines :origin nil
:width (if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0))
panels)
(push node order)
(when (eq host ebox-layer--render-root)
(dolist (portal ebox-layer--portals)
(unless (or (gethash portal panels) (memq portal queue))
(setq queue (append queue (list portal))))))))))
(dolist (node (nreverse order))
(let ((origin (resolve node)))
(when origin
(dolist (piece (ebox-layer--pieces (plist-get (gethash node panels) :lines) node))
(push (list :node node
:x (+ (car origin) (plist-get piece :start))
:y (+ (cadr origin) (plist-get piece :y))
:text (ebox-composite-slice (plist-get piece :line)
(plist-get piece :start)
(plist-get piece :end))) records))))))
records))
(defun ebox-layer-render-children (host children renderer)
"Compose HOST's CHILDREN using ordinary flow RENDERER.
RENDERER accepts a child list. Logical nodes are never reordered or removed.
Only candidate-local lists of visible paint records are sorted."
(let* ((flow (cl-remove-if (lambda (child)
(eq (plist-get child :position) 'absolute)) children))
(base (funcall renderer flow)))
(if ebox--intrinsic-layout-measurement base
(let* ((ebox-layer--piece-width-cache (make-hash-table :test #'eq))
(lines (ebox-string-lines base))
(width (or (ebox--wrapper-content-viewport-pixel host)
(if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0)))
(flow-height (length lines))
(height (max flow-height (ceiling (ebox--content-height host flow-height base))))
(absolute (cl-remove-if-not (lambda (child)
(eq (plist-get child :position) 'absolute)) children))
records)
(dolist (child children) (ebox-layer--validate-child child host))
;; Normal children preserve their layout slots. Relative children use
;; the same source intervals and shift only their paint coordinates.
(dolist (child flow)
(unless (eq (plist-get child :visibility) 'hidden)
(let ((dx (if (eq (plist-get child :position) 'relative)
(ebox-layer--offset child :left host width) 0))
(dy (if (eq (plist-get child :position) 'relative)
(ebox-layer--offset child :top host height) 0)))
(dolist (piece (ebox-layer--pieces lines child))
(push (list :node child :y (+ dy (plist-get piece :y))
:x (+ dx (plist-get piece :start))
:text (ebox-composite-slice (plist-get piece :line)
(plist-get piece :start)
(plist-get piece :end))) records)))))
(unless (eq host ebox-layer--render-root)
(dolist (child absolute)
(when (and (eq (plist-get child :layer) 'root)
(not (eq (plist-get child :visibility) 'hidden)))
(cl-pushnew child ebox-layer--portals :test #'eq)))
(setq absolute (cl-remove-if (lambda (child)
(eq (plist-get child :layer) 'root)) absolute)))
(when (eq host ebox-layer--render-root)
(setq absolute (append absolute (reverse ebox-layer--portals))))
(when (cl-some (lambda (child) (plist-get child :anchor)) absolute)
;; Anchor geometry follows relative movement and visible clipping,
;; not the original flow slot. This extra composition is needed
;; only by anchored panels.
(setq lines (ebox-layer--compose-rows host records width height
'anchor-prepass)))
(setq records (nconc (ebox-layer--absolute-records host absolute lines width height)
records))
(ebox--record-rendered-intrinsic-size
(ebox--record-rendered-uniform-width
(ebox-lines-join (ebox-layer--compose-rows host records width height)) width)
width flow-height)))))
(provide 'ebox-layer)
;;; ebox-layer.el ends here