ebox/ebox.el
Kinneyzhang 8a8e862098 feat(ebox): publish standalone low-level package
Split the verified renderer, layout engine, Grid support, native boundary, tests, examples, and paired documentation into the independent Ebox repository. Keep ETAF and application concerns outside this package.
2026-08-05 09:15:35 +08:00

7912 lines
361 KiB
EmacsLisp
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;; ebox.el --- Pixel-perfect box model renderer -*- lexical-binding: t -*-
;; Author: ebox contributors
;; Version: 1.0.1
;; Package-Requires: ((emacs "29.1"))
;; Keywords: ui, graphics, convenience
;; URL: https://github.com/ginqi7/ebox
;;; Commentary:
;; Ebox provides a high-performance, CSS-like box model implementation for Emacs.
;; It renders text content with padding, borders, margins, and background colors
;; using pixel-precise alignment.
;;
;; This file is the public facade and current implementation host. Internal
;; model modules are loaded here first; behavior moves into them through staged
;; extraction commits while this feature name remains the user-facing entry.
;;; Code:
(defconst ebox--directory
(file-name-directory (or load-file-name buffer-file-name))
"Directory containing the active Ebox Lisp sources.")
(defconst ebox--compile-sources
'("ebox-cache.el" "ebox-style.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-dsl.el" "ebox-selector.el" "ebox.el" "ebox-native-reflow.el")
"Active Ebox Lisp sources compiled by `ebox-byte-compile'.")
(add-to-list 'load-path ebox--directory)
(require 'cl-lib)
(require 'mwheel)
(require 'seq)
(require 'subr-x)
(require 'ebox-cache)
(require 'ebox-style)
(require 'ebox-tree)
(require 'ebox-measure)
(require 'ebox-fragment)
(require 'ebox-render-context)
(require 'ebox-layout)
(require 'ebox-flex)
(require 'ebox-grid)
(require 'ebox-buffer-backend)
(require 'ebox-incremental)
(require 'ebox-dsl)
(require 'ebox-selector)
(declare-function ebox--buffer-update-report "ebox-incremental" (buffer))
(declare-function ebox--set-buffer-update-report
"ebox-incremental" (buffer report))
(unless (fboundp 'ebox-native-build)
(autoload 'ebox-native-build "ebox-native-reflow" nil t))
(unless (fboundp 'ebox-native-status)
(autoload 'ebox-native-status "ebox-native-reflow" nil t))
(autoload 'ebox-native-reflow-render-owner-proof "ebox-native-reflow")
(setq ebox-buffer-owner-proof-renderer
#'ebox-native-reflow-render-owner-proof)
(defalias 'ebox-select-all #'ebox-selector-query-buffer)
(defalias 'ebox-update-selector #'ebox-selector-update-buffer)
(defgroup ebox nil
"Pixel-perfect box model renderer for Emacs buffers."
:group 'applications
:prefix "ebox-")
;;;###autoload
(defun ebox-byte-compile ()
"Recompile every active Ebox Lisp source into a neighboring `.elc' file.
The command is safe to run repeatedly after updating Ebox source. Exact
compiler output is written to `*Ebox Byte Compile*'. Restart Emacs after a
successful build so the current process loads the new bytecode. This command
does not compile the optional Rust module."
(interactive)
(require 'bytecomp)
(let* ((sources
(mapcar (lambda (file) (expand-file-name file ebox--directory))
ebox--compile-sources))
(missing (cl-remove-if #'file-readable-p sources))
(byte-compile-log-buffer "*Ebox Byte Compile*")
(log (get-buffer-create byte-compile-log-buffer))
(load-prefer-newer t)
failures)
(when missing
(user-error "Ebox byte compilation blocked; missing source: %s"
(mapconcat #'file-name-nondirectory missing ", ")))
(unless (file-writable-p ebox--directory)
(user-error "Ebox byte compilation blocked; directory is not writable: %s"
ebox--directory))
(with-current-buffer log
(let ((inhibit-read-only t))
(erase-buffer)
(insert (format "Ebox byte compilation\n\nSource: %s\nFiles: %d\n\n"
ebox--directory (length sources)))))
(dolist (source sources)
(message "Ebox byte-compiling %s..." (file-name-nondirectory source))
(condition-case err
(unless (byte-compile-file source)
(push (file-name-nondirectory source) failures))
(error
(push (file-name-nondirectory source) failures)
(with-current-buffer log
(let ((inhibit-read-only t))
(goto-char (point-max))
(insert (format "\n[%s] %s\n"
(file-name-nondirectory source)
(error-message-string err))))))))
(if failures
(progn
(display-buffer log)
(user-error "Ebox byte compilation failed for: %s"
(mapconcat #'identity (nreverse failures) ", ")))
(message "Ebox byte compilation succeeded for %d files; restart Emacs to use the new .elc files"
(length sources))
t)))
(defcustom ebox-render-cache-max-entries 2048
"Maximum persistent rendered-body cache entries retained per buffer.
Continuous viewport and ordinary box resizing can create a distinct rendered
value for every width. Ebox keeps the newest entries in insertion order and
evicts older entries above this soft bound. The larger default retains a full
responsive flex resize working set; `ebox-render-cache-max-bytes' remains the
harder memory guard for property-rich natural-height renders."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-render-cache-max-bytes (* 32 1024 1024)
"Approximate persistent rendered-body cache byte budget per buffer.
Ebox strings store pixel geometry and ownership mainly in text properties, so
an entry-count limit alone cannot bound memory for natural-height documents.
Values above this insertion-order budget evict older entries; one value larger
than the complete budget is rendered normally but is not retained."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-render-root-cache-max-entries 16
"Maximum complete root-width outputs retained per buffer.
The default retains one maintained manual-resize round trip, including its
predicted widths. The separate byte budget remains the hard guard for these
large, property-rich strings, so they cannot crowd reusable subtree and flex
measurements or grow with an unbounded resize history."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-render-root-cache-max-bytes (* 8 1024 1024)
"Approximate byte budget for complete root-width outputs per buffer.
This budget complements `ebox-render-root-cache-max-entries'; oversized root
outputs remain correct but are not retained after rendering."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-scroll-step 1
"Default number of lines moved by ebox scroll commands."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-wheel-scroll-step 16
"Default number of lines moved by one vertical mouse wheel event.
This is separate from `ebox-scroll-step' so keyboard fine scrolling can stay
precise while real mouse and trackpad scrolling feels responsive."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-wheel-smooth-scroll t
"When non-nil, animate mouse wheel scrolling over several short steps."
:type 'boolean
:group 'ebox)
(defcustom ebox-wheel-smooth-scroll-interval 0.016
"Seconds between animated mouse wheel scroll steps."
:type 'number
:group 'ebox)
(defcustom ebox-wheel-smooth-scroll-lines-per-tick 4
"Number of content lines moved by each animated mouse wheel tick."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-wheel-smooth-scroll-target-ticks 8
"Approximate maximum number of timer ticks used to drain pending wheel input."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-scroll-lazy-prefix-lookahead-lines 8
"Extra lines rendered ahead when extending lazy scroll prefixes.
This keeps repeated fine scroll steps on the marker-window patch path without
materializing the whole scroll source."
:type 'natnum
:group 'ebox)
(defcustom ebox-scroll-lazy-idle-prefetch-lines 128
"Extra lazy scroll lines rendered while Emacs is idle.
This warms the same scroll content cache used by wheel/key scrolling, so deep
scrolling can replace already-rendered line strings instead of producing
hidden rows inside a wheel tick. Set to 0 to disable idle prefetch."
:type 'natnum
:group 'ebox)
(defcustom ebox-scroll-lazy-idle-prefetch-slice-lines 16
"Maximum lazy scroll lines rendered by one idle prefetch callback.
Idle prefetch may need a larger total lookahead, but each callback stays
bounded so resize-time warming does not monopolize the GUI event loop. Set to
0 to allow one callback to fill the full lookahead target."
:type 'natnum
:group 'ebox)
(defcustom ebox-scroll-lazy-idle-prefetch-delay 0.15
"Idle seconds to wait before warming lazy scroll prefixes."
:type 'number
:group 'ebox)
(defcustom ebox-runtime-idle-prewarm t
"When non-nil, prepare shared incremental-update state while Emacs is idle.
Prewarming is buffer-runtime scoped and never materializes complete lazy scroll
content. It prepares indexes only for scroll lines that are already cached."
:type 'boolean
:group 'ebox)
(defcustom ebox-runtime-idle-prewarm-delay 0.1
"Idle seconds to wait before preparing shared incremental-update state."
:type 'number
:group 'ebox)
(defcustom ebox-runtime-idle-prewarm-prefix-resume-delay 2.0
"Idle seconds reserved for interaction before lazy prefix prefetch resumes.
This delay is used only when runtime prewarming has already made prefix
prefetch yield; ordinary prefix scheduling keeps its own configured delay."
:type 'number
:group 'ebox)
(defcustom ebox-runtime-idle-prewarm-slice-size 32
"Maximum nodes or cached scroll lines processed by one prewarm callback."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-runtime-idle-reflow-cache-prewarm t
"When non-nil, predict and cache the next steady viewport reflow.
Each completed nonzero viewport-width delta predicts one following width. The
predicted render runs after an input-free idle interval and publishes no
buffer or runtime state; it populates the same persistent render cache used by
viewport and ordinary root-width owner rerenders. Nonpositive predictions
are skipped."
:type 'boolean
:group 'ebox)
(defcustom ebox-runtime-idle-reflow-cache-prewarm-delay 0.15
"Idle seconds before a predicted viewport reflow warms render caches.
User input resets the idle period, so wheel scrolling and editing cannot be
overtaken by a full invisible layout. Large Playground resize animations use
their bounded native preparation pool instead of this main-thread timer."
:type 'number
:group 'ebox)
(defcustom ebox-reflow-cache-prewarm-gc-cons-threshold 'auto
"Allocation budget used while an idle reflow is being prepared.
The default `auto' learns from measured allocation per predicted layout and
its GC-free duration. It aims to keep collections uncommon during expensive
resize sequences while retaining smaller heaps for inexpensive pages. This
is a pressure threshold, not a retained RSS cap. Set it to a byte count for a
fixed budget, or nil to inherit the surrounding `gc-cons-threshold'."
:type '(choice (const :tag "Adapt to measured layout cost" auto)
(const :tag "Inherit surrounding value" nil)
positive-integer)
:group 'ebox)
(defcustom ebox-reflow-cache-prewarm-gc-auto-frame-budget 0.2
"Target seconds available for one predicted layout and optional GC.
Automatic reflow GC tuning compares measured GC-free layout time with this
budget. It never delays or drops a render; it only selects the allocation
threshold used by later invisible prewarms."
:type 'number
:group 'ebox)
(defcustom ebox-reflow-cache-prewarm-gc-auto-min-threshold (* 64 1024 1024)
"Minimum allocation threshold selected for expensive predicted layouts."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-reflow-cache-prewarm-gc-auto-max-threshold (* 1024 1024 1024)
"Maximum allocation threshold selected for predicted layouts."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-reflow-cache-prewarm-gc-auto-initial-threshold
(* 512 1024 1024)
"Initial allocation threshold before a predicted layout has been measured."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-reflow-cache-prewarm-gc-auto-target-layouts 24
"Target expensive predicted layouts between automatic collections.
Automatic tuning multiplies this horizon by measured allocation per layout.
Faster layouts use a shorter horizon because their collections consume less
of the resize cadence."
:type 'positive-integer
:group 'ebox)
(defcustom ebox-reflow-cache-prewarm-gc-cons-percentage 0.1
"Heap-relative allocation budget used during idle reflow preparation.
Emacs combines this with `ebox-reflow-cache-prewarm-gc-cons-threshold', so the
effective budget grows with the live working set while garbage collection is
kept inside invisible preparation rather than visible buffer publication.
Set it to nil to inherit the surrounding `gc-cons-percentage'."
:type '(choice (const :tag "Inherit surrounding value" nil) number)
:group 'ebox)
;;; ============================================================
;;; Core: Data Structures & String Utils
;;; ============================================================
(defvar ebox--region-id-counter 0
"Global counter for generating unique box region IDs.")
(defvar ebox--runtime-node-id-counter 0
"Global counter for internal runtime node IDs.")
(defvar ebox--render-region-id nil
"Dynamically bound region ID reused during whole-box rerender.")
(defvar ebox-viewport-width nil
"Dynamically bound horizontal viewport width in pixels.
DSL width values may use `(viewport)' to resolve to this value while a
caller, such as the Playground preview, renders against a concrete window.")
(defvar ebox-viewport-height nil
"Dynamically bound vertical viewport height in lines.
DSL height values may use `(viewport-height)' to resolve to this value while a
caller, such as the Playground preview, renders against a concrete window.")
(defvar ebox--region-box-table)
(defvar ebox-region-types)
(defvar ebox--scroll-global-state)
(defvar ebox--smooth-scroll-state-table)
(defvar ebox--render-cache-scroll-state-region-ids nil
"Dynamically bound set of scroll regions touched by one cached render.")
(defvar ebox--scroll-idle-prefetch-timers (make-hash-table :test 'equal)
"Region-keyed idle timers that warm lazy scroll prefix caches.")
(defvar ebox--scroll-idle-prefetch-inhibited-buffers
(make-hash-table :test 'eq)
"Buffers whose active foreground animation owns idle render time.")
(defvar ebox--scroll-allow-noninteractive-prefetch nil
"Non-nil lets tests schedule lazy scroll prefetch timers in batch Emacs.")
(defvar ebox--runtime-prewarm-allow-noninteractive nil
"Non-nil lets tests run buffer runtime prewarming in batch Emacs.")
(defvar ebox--runtime-prewarm-jobs (make-hash-table :test 'eq)
"Buffer-keyed incremental-update prewarm jobs.")
(defvar ebox--runtime-prewarm-timers (make-hash-table :test 'eq)
"Buffer-keyed idle timers for incremental-update prewarming.")
(defvar ebox--reflow-cache-prewarm-timers (make-hash-table :test 'eq)
"Buffer-keyed idle timers for predicted reflow-cache warming.")
(defvar ebox--scroll-prefetch-delay-override nil
"Dynamically bound delay for scroll prefix prefetch scheduling.")
(defvar ebox--region-update-buffer-hint nil
"Optional buffer hint for internal selector-driven region updates.")
(defsubst ebox--next-region-id ()
"Generate next unique region ID."
(cl-incf ebox--region-id-counter))
(defconst ebox--longhand
'( :content ""
:box-sizing border-box ;; content-box or border-box
;; 所有宽度的单位是像素,所有高度的单位是行数
:width nil :min-width 0 :max-width nil
:height nil :min-height 0 :max-height nil
:padding-left-pixel 0 :padding-right-pixel 0
:padding-top-height 0 :padding-bottom-height 0
:margin-left-pixel 0 :margin-right-pixel 0
:margin-top-height 0 :margin-bottom-height 0
;; 左右边框支持设置宽度
:border-left-pixel 0 :border-left-color nil
:border-right-pixel 0 :border-right-color nil
;; 上下边框由于 Emacs 限制,用 overline/underline 实现,不支持设置宽度
:border-top-p nil :border-top-color nil
:border-bottom-p nil :border-bottom-color nil
:color nil
:bgcolor nil
:text-align left :vertical-align top
;; supports: 'visible / 'hidden / 'scroll
:overflow scroll
;; supports: 'visible / 'hidden — hidden suppresses ink while the
;; layout footprint, buffer text, and metadata stay identical
:visibility visible
;; supports: 'word / 'char / 'kp
:wrap-mode word
:scroll-offset 0 ;; 当前滚动偏移(行数)
)
"Default plist for a box structure (longhand properties only).")
(defun ebox--longhand-create (&optional plist)
"Create a shorthand box structure.
PLIST is a property list that overrides the defaults."
(let ((box (copy-sequence ebox--longhand)))
(while plist
(let ((key (pop plist))
(value (pop plist)))
(plist-put box key value)))
box))
(defsubst ebox-get (box property)
"Retrieve PROPERTY from BOX."
(plist-get box property))
(defsubst ebox-put (box property value)
"Set PROPERTY to VALUE in BOX and return the modified box."
(plist-put box property value))
;;;###autoload
(defun ebox-buffer-update-report (buffer-or-name)
"Return BUFFER-OR-NAME's stored update report.
The returned plist is a defensive copy of the report owned by the live rendered
buffer. Signal `user-error' when the target is missing, dead, or has no Ebox
runtime. A freshly rendered buffer returns nil until its first update
publication."
(let ((buffer (and buffer-or-name (get-buffer buffer-or-name))))
(unless (buffer-live-p buffer)
(user-error "Ebox report target is not a live buffer: %S"
buffer-or-name))
(unless (ebox--buffer-render-state buffer)
(user-error "Ebox report target has no rendered runtime: %S"
buffer-or-name))
(copy-tree (ebox--buffer-update-report buffer))))
(defun ebox--ensure-region-id (box)
"Return BOX's stable region id, creating one if needed."
(or (ebox-get box :region-id)
(let ((id (ebox--next-region-id)))
(ebox-put box :region-id id)
id)))
(defun ebox--next-runtime-node-id ()
"Return the next internal runtime node id."
(cl-incf ebox--runtime-node-id-counter))
(defun ebox--ensure-node-id (node)
"Return NODE's stable internal node id, assigning one when needed."
(unless (and (listp node) (plist-get node :node-id))
(plist-put node :node-id (ebox--next-runtime-node-id)))
(plist-get node :node-id))
(defsubst ebox-string-lines (string)
"Split STRING into a list of lines, preserving text properties."
(when string
(let ((start 0)
(lines nil))
(while (string-match "\n" string start)
(push (substring string start (match-beginning 0)) lines)
(setq start (match-end 0)))
(push (substring string start) lines)
(nreverse lines))))
(defsubst ebox-string-height (string)
"Return the number of lines in STRING.
Optimized: Counts newlines directly to avoid list allocation."
(1+ (cl-count ?\n string)))
(defsubst ebox-lines-join (lines)
"Join a list of LINES into a single string."
(string-join lines "\n"))
(defun ebox--string-repeat-lines (string count)
"Repeat a single line STRING for COUNT times."
(when (and string (> count 0))
(ebox-lines-join (make-list count string))))
(defun ebox--maplines (function string)
"Apply FUNCTION to each line of STRING and return the new string."
(ebox-lines-join (mapcar function (ebox-string-lines string))))
(defun ebox--apply-hidden-visibility (string)
"Return STRING with all ink suppressed and the footprint intact.
Runs that already render as display specs keep their spec and lose
face color. Glyph runs are measured with their faces still applied
and then covered by one pixel-exact display space, so line widths are
identical in fixed and variable pitch while nothing is drawn. Buffer
text and every other text property (region, content, and owner
metadata, event surfaces) stay untouched, which keeps spans, markers,
and incremental diffs byte-compatible with the visible rendering."
(ebox--maplines
(lambda (line)
(if (string-empty-p line)
line
;; Measure the complete visible line first (faces still applied,
;; display specs included), then cover every character with its
;; own blank display space. Per-character specs follow the
;; engine's measurement convention, and the integer widths sum
;; exactly to the visible line's width by construction.
(let* ((width (ebox--string-pixel-width line))
(line (copy-sequence line))
(length (length line))
(base (/ width length))
(remainder (- width (* base length))))
(remove-text-properties
0 length '(face nil mouse-face nil) line)
(dotimes (index length)
(put-text-property
index (1+ index) 'display
`(space :width (,(if (< index remainder) (1+ base) base)))
line))
line)))
string))
(defun ebox--maplines-list (function lines)
"Apply FUNCTION to each line in the list LINES and return a new list."
(mapcar function lines))
(defun ebox--line-has-non-content-properties-p (line)
"Return non-nil when LINE carries properties other than content markers."
(catch 'found
(let ((pos 0)
(len (length line)))
(while (< pos len)
(let ((props (text-properties-at pos line)))
(while props
(let ((prop (pop props)))
(pop props)
(unless (memq prop '(display
ebox-content ebox-content-idx
ebox-content-owner
ebox-content-owners))
(throw 'found t)))))
(setq pos (or (next-property-change pos line) len))))
nil))
(defun ebox--lines-pad-vertical (string height &optional offset padding-string)
"Extend STRING to HEIGHT lines.
OFFSET controls content shift: positive (top), negative (bottom).
PADDING-STRING is used to fill empty lines, defaults to empty string."
(let* ((lines (ebox-string-lines string))
(line-count (length lines))
(padding-string (or padding-string
(ebox--pixel-blank
(ebox--string-pixel-width string) 1))))
(when (> height line-count)
(let* ((rest (- height line-count))
(offset (or offset 0))
(top-pad (cond ((>= offset 0) (min offset rest))
(t (max 0 (+ offset rest)))))
(bottom-pad (- rest top-pad))
(pad-lines (make-list top-pad padding-string))
(bottom-lines (make-list bottom-pad padding-string)))
(setq lines (append pad-lines lines bottom-lines))))
(ebox-lines-join (seq-take lines height))))
(defun ebox--lines-align-vertical (string height align)
"Align STRING to HEIGHT lines.
ALIGN can be `top', `center', or `bottom'."
(let* ((line-count (ebox-string-height string))
(offset (pcase align
('top 0)
('bottom (- height line-count))
('center (/ (- height line-count) 2))
(_ 0))))
(ebox--lines-pad-vertical string height offset)))
(defun ebox--string-lines-pad-bottom (string height)
"Return STRING lines padded at the bottom to HEIGHT."
(let* ((lines (ebox-string-lines string))
(line-count (length lines))
(extra (- height line-count)))
(if (<= extra 0)
lines
(let* ((blank-width (ebox--string-pixel-width string))
(blank (or (ebox--pixel-blank blank-width 1) "")))
(append lines (make-list extra blank))))))
(defun ebox--lines-concat-horizontal (&rest strings)
"Concatenate STRINGS horizontally, aligning to the tallest string."
(setq strings (delq nil strings))
(when strings
(let* ((heights (mapcar #'ebox-string-height strings))
(max-height (apply #'max heights))
(line-lists
(mapcar (lambda (string)
(ebox--string-lines-pad-bottom string max-height))
strings)))
(ebox-lines-join
(apply #'cl-mapcar #'concat line-lists)))))
(defun ebox--lines-stack-vertical (&rest strings)
"Stack STRINGS vertically."
(setq strings (delq nil strings))
(ebox-lines-join strings))
(defsubst ebox-default-foreground ()
"Return the default face foreground color."
(face-attribute 'default :foreground nil t))
(defsubst ebox-default-background ()
"Return the default face background color."
(face-attribute 'default :background nil t))
;;; ============================================================
;;; Tree Identity And DSL Helpers
;;; ============================================================
(defun ebox--extract-region-id (string)
"Extract region-id from rendered box STRING by finding first ebox-content."
(let ((pos 0)
(len (length string)))
(catch 'found
(while (< pos len)
(when-let ((id (get-text-property pos 'ebox-content string)))
(throw 'found id))
(setq pos (or (next-single-property-change
pos 'ebox-content string)
len))))))
(defun ebox--string-region-ids (string)
"Return all distinct region-ids found in STRING, preserving first-seen order."
(let ((pos 0)
(len (length string))
ids)
(while (< pos len)
(when-let ((id (get-text-property pos 'ebox-content string)))
(unless (memq id ids)
(setq ids (append ids (list id)))))
(setq pos (1+ pos)))
ids))
;;;###autoload
(defun ebox-region-ids (node)
"Return NODE's region ids in document order.
NODE can be an unrendered layout tree or an already-rendered string.
For layout trees, missing ids are assigned to box plists and remain stable
for later renders and dynamic updates.
Use this to capture ids *before* inserting into a buffer:
(let* ((layout (ebox-stack info-box (ebox-concat left right)))
(ids (ebox-region-ids layout))
(info-id (nth 0 ids))
(left-id (nth 1 ids))
(right-id (nth 2 ids)))
(ebox-render-to-buffer \"*my-ui*\" layout)
;; now use info-id / left-id / right-id for dynamic updates
)"
(cond
((stringp node)
(ebox--string-region-ids node))
((not (listp node)) nil)
(t
(let ((type (plist-get node :ebox-type)))
(cond
((eq type 'box)
(cons (ebox--ensure-region-id node)
(ebox-region-ids (plist-get node :ebox-content-node))))
((eq type 'concat)
(apply #'append
(mapcar #'ebox-region-ids
(ebox--layout-children node))))
((eq type 'stack)
(apply #'append
(mapcar #'ebox-region-ids
(ebox--layout-children node))))
((eq type 'flex)
(if-let ((box (plist-get node :box)))
(list (ebox--ensure-region-id box))
(apply #'append
(mapcar #'ebox-region-ids (plist-get node :children)))))
((eq type 'grid)
(if-let ((box (plist-get node :box)))
(list (ebox--ensure-region-id box))
(apply #'append
(mapcar #'ebox-region-ids (plist-get node :children)))))
((eq type 'flex-item)
(ebox-region-ids (plist-get node :node)))
(t nil))))))
(defun ebox--runtime-node-ids (node)
"Return internal runtime node ids for NODE and its children."
(cond
((or (stringp node) (not (listp node))) nil)
(t
(cons (ebox--ensure-node-id node)
(apply #'append
(mapcar #'ebox--runtime-node-ids
(ebox--node-children node)))))))
(defun ebox--runtime-tree (node)
"Return NODE after assigning runtime ids to every renderable node."
(ebox--runtime-node-ids node)
node)
(defun ebox--runtime-node-key (node)
"Return NODE's explicit runtime key, or nil."
(when (and (listp node)
(plist-member node :key))
(plist-get node :key)))
(defun ebox--runtime-same-type-p (old new)
"Return non-nil when OLD and NEW have the same runtime node type."
(and (listp old)
(listp new)
(eq (plist-get old :ebox-type)
(plist-get new :ebox-type))))
(defun ebox--runtime-node-id-by-key (node key)
"Return the first runtime node id under NODE whose :key equals KEY."
(when (and key
(listp node)
(not (stringp node)))
(or (when (equal (ebox--runtime-node-key node) key)
(ebox--ensure-node-id node))
(cl-some (lambda (child)
(ebox--runtime-node-id-by-key child key))
(ebox--node-children node)))))
(defun ebox--keyed-runtime-children (children)
"Return a KEY -> child hash table for explicitly keyed CHILDREN."
(let ((table (make-hash-table :test 'equal)))
(dolist (child children)
(when-let ((key (ebox--runtime-node-key child)))
(puthash key child table)))
table))
(defun ebox--runtime-positional-match (old-child new-child)
"Return OLD-CHILD when NEW-CHILD can reuse its positional identity."
(when (and (not (ebox--runtime-node-key old-child))
(not (ebox--runtime-node-key new-child))
(ebox--runtime-same-type-p old-child new-child))
old-child))
(defun ebox--runtime-keyed-match (old-keyed new-child)
"Return the keyed old child that should match NEW-CHILD."
(when-let ((key (ebox--runtime-node-key new-child)))
(let ((old-child (gethash key old-keyed)))
(when (ebox--runtime-same-type-p old-child new-child)
old-child))))
(defun ebox--assign-runtime-node-id-from-match (old new)
"Assign NEW retained runtime identity from OLD when possible."
(ebox-tree-transfer-runtime-identity old new))
(defun ebox--reconcile-runtime-tree (old new)
"Transfer stable runtime identities from OLD to matching nodes in NEW.
Matching rules are deliberately small: a child with an explicit :key only
matches an old sibling with the same key and node type; unkeyed children may
reuse same-position ids when their node type is unchanged. Retained boxes
also keep their region ids so buffer properties remain aligned with runtime
indexes. This prepares component identity without exposing a component API."
(ebox-tree-reconcile-runtime old new))
;;;###autoload
(defun ebox-build (dsl)
"Build an Ebox node from an ETML-style list DSL."
(ebox-dsl-build dsl))
(defmacro ebox--to-buffer (buffer-or-name switch-fn &rest body)
"Render into BUFFER-OR-NAME using SWITCH-FN, executing BODY."
(declare (indent 2))
`(let ((buffer (get-buffer-create ,buffer-or-name)))
(with-current-buffer buffer
(let ((inhibit-read-only t))
(erase-buffer)
,@body)
(local-set-key "q" (lambda () (interactive)
(local-unset-key "q")
(quit-window)))
(read-only-mode 1)
(funcall ,switch-fn buffer))))
(defmacro ebox-pop-to-buffer (buffer-or-name &rest body)
(declare (indent defun))
`(ebox--to-buffer ,buffer-or-name #'pop-to-buffer ,@body))
(defmacro ebox-switch-to-buffer (buffer-or-name &rest body)
(declare (indent defun))
`(ebox--to-buffer ,buffer-or-name #'switch-to-buffer ,@body))
;;; ============================================================
;;; Property Rules: 配置驱动的 Shorthand/Longhand 映射
;;; ============================================================
;;
;; 规则格式自描述,无需 type 标记:
;; - 值是 list → shorthand展开到列表中的属性
;; - 值是 (keyword . converter) → longhand转换后写入目标
;; - 值是 keyword → 直通,属性名不变
(defconst ebox-property-rules ebox-style-ebox-property-rules
"Compatibility alias for Ebox public property expansion rules.")
;;; --- 值转换器 ---
(defun ebox--convert (value converter)
"Convert VALUE using CONVERTER."
(ebox-style--convert value converter))
;;; --- 值拆分器 ---
(defun ebox--split-trbl (value)
"Split VALUE using CSS TRBL rules → (top right bottom left)."
(ebox-style--split-trbl value))
(defun ebox--split-pair (value)
"Split VALUE into pair → (start end)."
(ebox-style--split-pair value))
(defconst ebox--border-styles
'(none hidden dotted dashed solid double groove ridge inset outset)
"Valid CSS border-style keywords.")
(defun ebox--wsc-classify (item)
"Classify ITEM as width, style, or color. Returns (type . value)."
(ebox-style--wsc-classify item))
(defun ebox--split-wsc (value)
"Split VALUE into (width style color) using CSS type-based parsing.
Values can appear in any order; each is identified by type."
(ebox-style--split-wsc value))
(defun ebox--split-value (value mode n)
"Split VALUE according to MODE for N targets."
(ebox-style--split-value value mode n))
;;; --- 核心展开函数 ---
(defun ebox--get-rule (property)
"Get rule for PROPERTY from `ebox-property-rules'."
(ebox-style--get-ebox-rule property))
(defun ebox--expand-property (property value)
"Expand PROPERTY with VALUE according to rules. Returns a plist."
(ebox-style-expand-ebox-property property value))
;;; --- 辅助函数 ---
(defun ebox--atom-consp (list)
"Check if LIST is a strict dotted pair (atom . atom)."
(ebox-style--atom-consp list))
(defun ebox--parse-color (color)
"Parse COLOR to a real color string.
Supports: t (default fg), nil, string, (light . dark) cons."
(ebox-style--parse-color color))
(defun ebox--expand-plist (plist)
"Expand PLIST using `ebox-property-rules'."
(ebox-style-expand-ebox-plist plist))
;;; --- 核心 API ---
;;;###autoload
(defun ebox-create (&rest plist)
"Create a box from PLIST. Supports all shorthand and longhand properties.
Properties are expanded recursively according to `ebox-property-rules'."
(let ((expanded (ebox--expand-plist plist))
(controlled-scroll-offset-p (plist-member plist :scroll-offset)))
(let ((box (ebox--longhand-create expanded)))
(plist-put box :ebox-type 'box)
(plist-put box :display '(block flow))
(when controlled-scroll-offset-p
(plist-put box :ebox-scroll-offset-controlled-p t))
box)))
;;; ============================================================
;;; Buffer Runtime State & Scroll State
;;; ============================================================
;; Incremental runtime model boundaries:
;; - Source Tree Model: user/DSL plists with stable :region-id and :node-id.
;; - Render Context Model: dynamic bindings such as `ebox-viewport-width'.
;; - Buffer Runtime Model: per-buffer root node, viewport, and node indexes.
;; - Layout Snapshot Model: derived render facts used for diffing.
;; - Dirty/Patch Model: executable update choices for buffer edits.
;;
;; Dynamic render-local, flex-local, and snapshot-detail-local caches are
;; optimizations. Do not make them public API or component-owned state.
(defvar ebox--region-box-table (make-hash-table :test 'equal)
"Maps region-id to its box definition for dynamic content updates.")
(defvar ebox--box-extents (make-hash-table :test 'equal)
"Maps region-id to (start-marker . end-marker) for contiguous box roots.
Only boxes occupying one contiguous buffer slice are stored here.")
(defvar-local ebox--box-extent-template nil
"Prepared numeric box extents for the current root publication.")
(defvar ebox--defer-scroll-content-index nil
"Non-nil means full scroll content indexes are built lazily.")
(defun ebox--scroll-get-state (region-id)
"Get scroll state for REGION-ID."
(gethash region-id ebox--scroll-global-state))
(defun ebox--scroll-cancel-idle-prefetch (region-id)
"Cancel any pending idle prefix prefetch for REGION-ID."
(when-let ((timer (gethash region-id ebox--scroll-idle-prefetch-timers)))
(when (timerp timer)
(cancel-timer timer))
(remhash region-id ebox--scroll-idle-prefetch-timers)))
(defun ebox--scroll-inhibit-idle-prefetch-for-buffer (buffer)
"Pause lazy scroll prefix work while BUFFER has a foreground animation."
(when (buffer-live-p buffer)
(puthash buffer t ebox--scroll-idle-prefetch-inhibited-buffers)
(maphash
(lambda (region-id state)
(when (eq buffer (ebox--scroll-state-buffer state))
(ebox--scroll-cancel-idle-prefetch region-id)))
ebox--scroll-global-state)
t))
(defun ebox--scroll-resume-idle-prefetch-for-buffer (buffer)
"Resume useful lazy scroll prefix work after BUFFER's animation."
(when buffer
(remhash buffer ebox--scroll-idle-prefetch-inhibited-buffers)
(let (region-ids)
(maphash
(lambda (region-id state)
(when (and (eq buffer (ebox--scroll-state-buffer state))
(ebox--scroll-idle-prefetch-needed-p state))
(push region-id region-ids)))
ebox--scroll-global-state)
(dolist (region-id region-ids)
(ebox--scroll-schedule-idle-prefetch region-id)))))
(defun ebox--scroll-idle-prefetch-needed-p (state)
"Return non-nil when STATE can benefit from idle prefix prefetch."
(and (> (or ebox-scroll-lazy-idle-prefetch-lines 0) 0)
(plist-get state :render-content-prefix)
(not (plist-get state :content-lines-complete-p))))
(defun ebox--idle-continuation-delay (delay)
"Return an idle-timer threshold one fresh DELAY beyond current idle time."
(+ (or (when-let ((idle (current-idle-time)))
(float-time idle))
0)
(max 0 (or delay 0))))
(defun ebox--scroll-schedule-idle-prefetch (region-id &optional delay)
"Schedule input-yielding lazy prefix prefetch for REGION-ID when useful."
(ebox--scroll-cancel-idle-prefetch region-id)
(when-let ((state (and (or (not noninteractive)
ebox--scroll-allow-noninteractive-prefetch)
(ebox--scroll-get-state region-id))))
(when (and (ebox--scroll-idle-prefetch-needed-p state)
(let ((buffer (ebox--scroll-state-buffer state)))
(not (and buffer
(gethash
buffer
ebox--scroll-idle-prefetch-inhibited-buffers)))))
(puthash
region-id
(run-with-idle-timer
(max 0 (or delay ebox-scroll-lazy-idle-prefetch-delay 0))
nil #'ebox--scroll-idle-prefetch region-id)
ebox--scroll-idle-prefetch-timers))))
(defun ebox--scroll-prefetch-slice-lines ()
"Return a bounded urgent lazy prefetch slice size in lines."
(max 1
(or ebox-scroll-step 1)
(or ebox-wheel-smooth-scroll-lines-per-tick 1)
(or ebox-wheel-scroll-step 1)))
(defun ebox--scroll-sync-prefix-render-p ()
"Return non-nil when scroll input may synchronously render lazy prefixes."
(or noninteractive
(not (display-graphic-p))))
(defun ebox--scroll-schedule-cache-miss-prefetch (region-id state)
"Schedule a bounded urgent prefetch for lazy scroll STATE."
(if (ebox--scroll-idle-prefetch-needed-p state)
(let* ((current-lines (length (plist-get state :content-lines)))
(target-lines
(+ current-lines (ebox--scroll-prefetch-slice-lines))))
(setq state
(plist-put state :cache-miss-prefetch-target-lines
target-lines))
(puthash region-id state ebox--scroll-global-state)
(ebox--scroll-schedule-idle-prefetch region-id 0)
state)
state))
(defun ebox--scroll-idle-prefetch-slice-lines (&optional state)
"Return STATE's bounded idle prefetch slice, or nil for unbounded.
Composite rows can hide expensive flex work. Limit latency-sensitive buffers
to one newly requested line per idle turn so one generic slice cannot cross
several costly rows before yielding back to input."
(let* ((slice (max 0 (or ebox-scroll-lazy-idle-prefetch-slice-lines 0)))
(buffer (and state (ebox--scroll-state-buffer state))))
(when (and (> slice 0)
buffer
(ebox--buffer-latency-sensitive-scroll-prefix-p buffer))
(setq slice 1))
(and (> slice 0) slice)))
(defun ebox--scroll-ensure-bounded-prefix-for-offset
(region-id state desired-offset &optional budget-lines)
"Extend lazy scroll STATE just enough to approach DESIRED-OFFSET.
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."
(let* ((content-lines (plist-get state :content-lines))
(content-height (or (plist-get state :content-height) 0))
(current-lines (length content-lines))
(max-offset (max 0 (- current-lines content-height))))
(if (or (<= desired-offset max-offset)
(not (plist-get state :render-content-prefix))
(plist-get state :content-lines-complete-p))
state
(let* ((visible-end (+ (max 0 desired-offset) content-height))
(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))
(ebox--scroll-state-ensure-prefix-lines
region-id state required-lines t)))))
(defun ebox--smooth-scroll-active-p (region-id)
"Return non-nil when REGION-ID has an active smooth scroll timer."
(when-let ((entry (gethash region-id ebox--smooth-scroll-state-table)))
(and (timerp (plist-get entry :timer))
(not (plist-get entry :waiting-prefetch)))))
(defun ebox--smooth-scroll-resume-after-prefetch (region-id)
"Resume REGION-ID smooth scrolling after a paused lazy prefetch."
(when-let ((entry (gethash region-id ebox--smooth-scroll-state-table)))
(when (plist-get entry :waiting-prefetch)
(plist-put entry :waiting-prefetch nil)
(unless (timerp (plist-get entry :timer))
(plist-put
entry :timer
(run-at-time ebox-wheel-smooth-scroll-interval
ebox-wheel-smooth-scroll-interval
#'ebox--smooth-scroll-tick region-id)))
(puthash region-id entry ebox--smooth-scroll-state-table))))
(defun ebox--scroll-idle-prefetch (region-id)
"Warm REGION-ID's lazy scroll prefix cache while Emacs is idle."
(remhash region-id ebox--scroll-idle-prefetch-timers)
(if (ebox--smooth-scroll-active-p region-id)
(ebox--scroll-schedule-idle-prefetch
region-id
(ebox--idle-continuation-delay
ebox-scroll-lazy-idle-prefetch-delay))
(when-let ((state (ebox--scroll-get-state region-id)))
(let ((buffer (ebox--scroll-state-buffer state))
(cache-miss-target
(plist-get state :cache-miss-prefetch-target-lines)))
(if (and buffer
(not cache-miss-target)
(gethash buffer ebox--runtime-prewarm-jobs))
(ebox--scroll-schedule-idle-prefetch
region-id
(ebox--idle-continuation-delay
ebox-runtime-idle-prewarm-prefix-resume-delay))
(when (ebox--scroll-idle-prefetch-needed-p state)
(let* ((defer-gc
(and buffer
(cl-some
(lambda (window)
(display-graphic-p (window-frame window)))
(get-buffer-window-list buffer nil t))))
(current-lines (length (plist-get state :content-lines)))
(prefetch-lines
(max 0 ebox-scroll-lazy-idle-prefetch-lines))
(visible-target-lines
(+ (or (plist-get state :scroll-offset) 0)
(or (plist-get state :content-height) 0)
prefetch-lines))
(target-lines
(if cache-miss-target
(max current-lines cache-miss-target)
(max (+ current-lines prefetch-lines)
visible-target-lines)))
(slice-lines (ebox--scroll-idle-prefetch-slice-lines state))
(bounded-target-lines
(if slice-lines
(min target-lines (+ current-lines slice-lines))
target-lines)))
(when defer-gc
(ebox--deferred-render-gc-enter))
(unwind-protect
(progn
;; A configured slice is the complete hidden-work budget
;; for this idle turn. The generic prefix helper normally
;; adds a completion sentinel and smooth-scroll lookahead;
;; exact mode defers both so a one-line slice cannot turn
;; back into a multi-row render and reintroduce input
;; stalls.
(let ((ebox-scroll-lazy-prefix-lookahead-lines
(if slice-lines
0
ebox-scroll-lazy-prefix-lookahead-lines))
(ebox--scroll-window-yielding-prewarm-p
(and slice-lines (= slice-lines 1))))
(setq state
(ebox--scroll-state-ensure-prefix-lines
region-id state bounded-target-lines
(and slice-lines t))))
(when (and cache-miss-target
(>= (length (plist-get state :content-lines))
cache-miss-target))
(setq state (ebox--plist-remove
state :cache-miss-prefetch-target-lines))
(puthash region-id state ebox--scroll-global-state))
(let ((prefix-still-needed-p
(ebox--scroll-idle-prefetch-needed-p state)))
(when prefix-still-needed-p
(ebox--scroll-schedule-idle-prefetch
region-id
(ebox--idle-continuation-delay
ebox-scroll-lazy-idle-prefetch-delay)))
;; Restart the shared tree/index prewarm once after the
;; lazy prefix settles. Restarting it after every
;; one-line slice makes each following slice yield for
;; the full priority delay and can starve idle prefix
;; progress indefinitely.
(when (and buffer (not prefix-still-needed-p))
(ebox--schedule-buffer-runtime-prewarm buffer)))
(ebox--smooth-scroll-resume-after-prefetch region-id))
(when defer-gc
(ebox--deferred-render-gc-schedule-restore))))))))))
(defun ebox--scroll-content-marker-spans (markers)
"Return MARKERS as a plain list of marker span conses."
(cond
((vectorp markers) (append markers nil))
((listp markers) markers)
(t nil)))
(defun ebox--scroll-line-spans-in-window (start line-count)
"Return LINE-COUNT line spans starting at START in one forward scan."
(save-excursion
(goto-char start)
(cl-loop repeat line-count
collect (cons (point) (line-end-position))
do (forward-line 1))))
(defun ebox--scroll-clear-content-markers (state)
"Detach content span markers stored in scroll STATE."
(dolist (span (ebox--scroll-content-marker-spans
(plist-get state :content-span-markers)))
(when (consp span)
(when (markerp (car span))
(set-marker (car span) nil))
(when (markerp (cdr span))
(set-marker (cdr span) nil)))))
(defun ebox--scroll-clear-rendered-window-markers (state)
"Detach rendered scroll window boundary markers stored in STATE."
(when-let ((start (plist-get state :rendered-window-start-marker)))
(when (markerp start)
(set-marker start nil)))
(when-let ((end (plist-get state :rendered-window-end-marker)))
(when (markerp end)
(set-marker end nil))))
(defun ebox--scroll-store-rendered-window-extents
(buffer region-id state start end line-count)
"Store lightweight rendered window extents for REGION-ID.
Unlike content span markers, these two markers are cheap enough for the scroll
hot path and are sufficient to anchor the next viewport replacement."
(when (and (buffer-live-p buffer)
(integer-or-marker-p start)
(integer-or-marker-p end)
(integerp line-count)
(> line-count 0))
(with-current-buffer buffer
(let ((start-pos (if (markerp start) (marker-position start) start))
(end-pos (if (markerp end) (marker-position end) end)))
(when (and start-pos end-pos (< start-pos end-pos))
(let ((start-marker
(or (and (markerp (plist-get
state :rendered-window-start-marker))
(plist-get state :rendered-window-start-marker))
(make-marker)))
(end-marker
(or (and (markerp (plist-get
state :rendered-window-end-marker))
(plist-get state :rendered-window-end-marker))
(make-marker))))
(set-marker-insertion-type start-marker nil)
(set-marker-insertion-type end-marker t)
(set-marker start-marker start-pos)
(set-marker end-marker end-pos)
(setq state
(plist-put state :rendered-window-start-marker
start-marker))
(setq state
(plist-put state :rendered-window-end-marker
end-marker)))
(setq state
(plist-put state :rendered-window-line-count line-count))
(setq state (plist-put state :buffer buffer))
(puthash region-id state ebox--scroll-global-state)
state)))))
(defun ebox--scroll-window-region-id-set (buffer ranges)
"Return all ebox region ids inside BUFFER RANGES as a hash set."
(let ((set (make-hash-table :test 'equal)))
(when (buffer-live-p buffer)
(with-current-buffer buffer
(dolist (range ranges)
(let ((pos (car range))
(end (cdr range)))
(while (< pos end)
(dolist (region-id (ebox--pos-region-ids pos))
(puthash region-id t set))
(setq pos (or (next-property-change pos nil end)
end)))))))
set))
(defun ebox--scroll-state-put-window-region-id-set (state value)
"Return STATE with cached visible region id set stored at the front."
(cons :content-window-region-id-set
(cons value
(ebox--plist-remove state :content-window-region-id-set))))
(defun ebox--scroll-state-store-window-region-id-set
(region-id state buffer ranges)
"Store visible region ids for REGION-ID/STATE from BUFFER RANGES."
(setq state
(ebox--scroll-state-put-window-region-id-set
state (ebox--scroll-window-region-id-set buffer ranges)))
(setq state (plist-put state :buffer buffer))
(puthash region-id state ebox--scroll-global-state)
state)
(defun ebox--scroll-state-clear-window-region-id-set
(region-id state &optional buffer)
"Clear the cached visible region id set for REGION-ID/STATE."
(when (or (plist-get state :content-window-region-id-set)
(and buffer (not (eq (plist-get state :buffer) buffer))))
(setq state (plist-put state :content-window-region-id-set nil))
(when buffer
(setq state (plist-put state :buffer buffer)))
(puthash region-id state ebox--scroll-global-state))
state)
(defun ebox--scroll-state-region-visible-in-window-p (state region-ids)
"Return non-nil when REGION-IDS appear in STATE's marker window."
(if-let ((region-set (plist-get state :content-window-region-id-set)))
(cl-some (lambda (region-id)
(gethash region-id region-set))
region-ids)
(when-let ((markers (plist-get state :content-span-markers)))
(let ((region-set (ebox--region-id-set region-ids)))
(catch 'visible
(dolist (span (ebox--scroll-content-marker-spans markers))
(when-let* ((start-marker (car-safe span))
(end-marker (cdr-safe span))
((markerp start-marker))
((markerp end-marker))
(buffer (marker-buffer start-marker))
((eq buffer (marker-buffer end-marker)))
(start (marker-position start-marker))
(end (marker-position end-marker))
((< start end)))
(with-current-buffer buffer
(let ((pos start))
(while (< pos end)
(when (cl-some (lambda (region-id)
(gethash region-id region-set))
(ebox--pos-region-ids pos))
(throw 'visible t))
(setq pos (or (next-property-change pos nil end)
end)))))))
nil)))))
(defun ebox--scroll-state-region-visible-in-content-range-p
(state region-ids)
"Return non-nil when REGION-IDS have spans in STATE's visible line range."
(when-let ((spans
(ebox--scroll-hot-content-line-spans
state region-ids)))
(ebox--scroll-state-spans-visible-p state spans)))
(defun ebox--scroll-state-buffer (state)
"Return the buffer associated with scroll STATE, or nil."
(or (plist-get state :buffer)
(catch 'buffer
(dolist (span (ebox--scroll-content-marker-spans
(plist-get state :content-span-markers)))
(when-let ((buffer (and (consp span)
(markerp (car span))
(marker-buffer (car span)))))
(throw 'buffer buffer)))
nil)))
;;;###autoload
(defun ebox-scroll-state (region-id)
"Return the scroll state plist for REGION-ID, or nil.
The plist is read-only API for consumers that need to map visible scroll
content back to their own source model."
(ebox--scroll-get-state region-id))
(defconst ebox--scroll-cache-transient-state-keys
'(:buffer
:content-span-markers
:content-window-region-id-set
:rendered-window-start-marker
:rendered-window-end-marker
:rendered-window-line-count
:content-region-id-set
:region-line-bounds-index
:region-line-span-index
:region-line-span-index-deferred
:rendered-region-line-span-index
:rendered-region-line-span-index-deferred
:region-line-span-hints
:lazy-scroll-prefix-dirty
:lazy-scroll-window-refresh-required
:native-reflow-target-prefix-p
:native-reflow-prefix-reset-p
:native-reflow-visible-offset
:native-reflow-visible-lines)
"Scroll state fields rebuilt when cached rendered output is published.")
(defvar ebox--render-cache-scroll-state-retained-cost-cache nil)
(defun ebox--scroll-cache-state-template (state)
"Return a markerless shallow template for cached scroll STATE.
Content line conses and the prefix renderer remain shared because that closure
owns the exact continuation cache for the captured viewport context."
(let ((plist state)
template)
(while plist
(let ((key (pop plist))
(value (pop plist)))
(unless (memq key ebox--scroll-cache-transient-state-keys)
(push key template)
(push value template))))
(nreverse template)))
(defun ebox--scroll-cache-line-list-retained-cost (lines)
"Return a cheap retained-byte estimate for cached scroll LINES."
(let ((total 0))
(dolist (line lines total)
(cl-incf total 16)
(when (stringp line)
(cl-incf total (+ 64 (string-bytes line)
(* 16 (length line))))))))
(defun ebox--scroll-cache-state-template-retained-cost (template)
"Return retained bytes charged to cached scroll state TEMPLATE."
(+ (* 16 (length template))
(ebox--scroll-cache-line-list-retained-cost
(plist-get template :content-lines))
(ebox--scroll-cache-line-list-retained-cost
(plist-get template :rendered-content-lines))
(if (plist-get template :render-content-prefix) 512 0)
(if (plist-get template :materialize-content-lines) 256 0)))
(defun ebox--scroll-cache-side-effects-for-region-ids (region-id-set)
"Return cached scroll actions and their retained cost for REGION-ID-SET."
(let ((retained-cost 64)
actions)
(when (hash-table-p region-id-set)
(maphash
(lambda (region-id _)
(cl-incf retained-cost 48)
(if-let ((state (ebox--scroll-get-state region-id)))
(let ((template (ebox--scroll-cache-state-template state)))
(cl-incf
retained-cost
(or (and (hash-table-p
ebox--render-cache-scroll-state-retained-cost-cache)
(gethash
state
ebox--render-cache-scroll-state-retained-cost-cache))
(let ((cost
(ebox--scroll-cache-state-template-retained-cost
template)))
(when (hash-table-p
ebox--render-cache-scroll-state-retained-cost-cache)
(puthash
state cost
ebox--render-cache-scroll-state-retained-cost-cache))
cost)))
(push (list 'set region-id template) actions))
(push (list 'clear region-id) actions)))
region-id-set))
(list :scroll-actions actions :retained-cost retained-cost)))
(defun ebox--record-render-cache-scroll-region (region-id)
"Record REGION-ID in the active rendered-node side-effect collector."
(when (hash-table-p ebox--render-cache-scroll-state-region-ids)
(puthash region-id t ebox--render-cache-scroll-state-region-ids)))
(defun ebox--scroll-cache-actions-portable-p (actions)
"Return non-nil when ACTIONS can move from an isolated to a live tree.
Complete scroll templates contain no continuation closures and can therefore
be rebound by node id. Lazy prefix closures retain their source tree and must
remain transaction-local."
(cl-every
(lambda (action)
(pcase action
(`(set ,_region-id ,template)
(and (null (plist-get template :render-content-prefix))
(null (plist-get template :materialize-content-lines))))
(`(clear ,_region-id) t)
(_ nil)))
actions))
(defun ebox--scroll-cache-live-box (region-id template)
"Return REGION-ID's live box corresponding to cached TEMPLATE."
(let* ((template-box (plist-get template :box))
(node-id (and template-box (ebox--ensure-node-id template-box)))
(node-table (ebox--buffer-node-table (current-buffer))))
(or (and node-table node-id (gethash node-id node-table))
(plist-get (ebox--scroll-get-state region-id) :box)
(gethash region-id ebox--region-box-table))))
(defun ebox--scroll-cache-actions-replayable-p (actions &optional portable)
"Return non-nil when cached scroll ACTIONS still match the live runtime."
(let ((node-table (ebox--buffer-node-table (current-buffer))))
(cl-every
(lambda (action)
(pcase action
(`(set ,region-id ,template)
(let* ((box (plist-get template :box))
(node-id (and box (ebox--ensure-node-id box)))
(live-state (ebox--scroll-get-state region-id))
(effective-box
(if portable
(ebox--scroll-cache-live-box region-id template)
box)))
(and effective-box
(equal (or (plist-get template :scroll-offset) 0)
(or (ebox-get effective-box :scroll-offset) 0))
(or portable
(null node-table)
(eq box (gethash node-id node-table)))
(or (null live-state)
(eq effective-box (plist-get live-state :box)))
(not (plist-get live-state :lazy-scroll-prefix-dirty))
(not (plist-get
live-state :lazy-scroll-window-refresh-required)))))
(`(clear ,_region-id) t)
(_ nil)))
actions)))
(defun ebox--replay-scroll-cache-actions (actions &optional portable)
"Replay cached scroll ACTIONS into the current render runtime."
(dolist (action actions)
(pcase action
(`(set ,region-id ,template)
(let ((state (copy-sequence template)))
(when portable
(plist-put state :box
(ebox--scroll-cache-live-box region-id template)))
(ebox--scroll-set-state region-id state)))
(`(clear ,region-id)
(ebox--scroll-clear-state region-id))
(_
(error "Invalid cached scroll action: %S" action))))
t)
(defun ebox--scroll-set-state (region-id state)
"Set scroll STATE for REGION-ID."
(when (bound-and-true-p
ebox--collect-rebuilt-scroll-state-region-ids)
(cl-pushnew region-id ebox--rebuilt-scroll-state-region-ids
:test #'equal))
(when-let ((old-state (ebox--scroll-get-state region-id)))
(ebox--scroll-clear-content-markers old-state)
(ebox--scroll-clear-rendered-window-markers old-state))
(when-let ((content-lines (plist-get state :content-lines)))
(let ((ebox--defer-scroll-content-index
(or ebox--defer-scroll-content-index
(plist-get state :render-content-prefix)
(plist-get state :materialize-content-lines))))
(setq state
(ebox--scroll-state-set-lines
state content-lines
(plist-get state :rendered-content-lines)))))
(puthash region-id state ebox--scroll-global-state)
(ebox--record-render-cache-scroll-region region-id)
(ebox--scroll-schedule-idle-prefetch
region-id ebox--scroll-prefetch-delay-override))
(defun ebox--scroll-clear-state (region-id)
"Remove scroll state for REGION-ID."
(ebox--scroll-cancel-idle-prefetch region-id)
(when-let ((old-state (ebox--scroll-get-state region-id)))
(ebox--scroll-clear-content-markers old-state)
(ebox--scroll-clear-rendered-window-markers old-state))
(remhash region-id ebox--scroll-global-state)
(ebox--record-render-cache-scroll-region region-id))
(defun ebox--scroll-state-rendered-visible-window (state)
"Return STATE's current rendered visible slice, including text properties."
(let* ((content-height (plist-get state :content-height))
(offset (or (plist-get state :scroll-offset) 0))
(native-offset (plist-get state :native-reflow-visible-offset))
(native-lines (plist-get state :native-reflow-visible-lines)))
(cond
((and (integerp content-height)
(>= content-height 0)
(equal native-offset offset)
(= (length native-lines) content-height))
(list offset native-lines))
((and (integerp content-height) (>= content-height 0))
(when-let ((lines (plist-get state :rendered-content-lines)))
(let* ((max-offset (max 0 (- (length lines) content-height)))
(offset (max 0 (min max-offset offset)))
(end (min (+ offset content-height) (length lines))))
(list offset (seq-subseq lines offset end))))))))
(defun ebox--scroll-rendered-visible-windows-equal-p (left right)
"Return non-nil when LEFT and RIGHT have identical text and properties."
(and left right
(= (car left) (car right))
(= (length (cadr left)) (length (cadr right)))
(cl-every #'equal-including-properties (cadr left) (cadr right))))
(defun ebox--scroll-state-set-visible-refresh-requirement
(state old-visible old-refresh-required)
"Return STATE with an exact visible-window refresh requirement.
OLD-VISIBLE is the rendered window before rebuilding cached lines. Preserve an
existing OLD-REFRESH-REQUIRED request, otherwise require a refresh only when
the currently displayed rendered slice actually changed."
(let ((new-visible (ebox--scroll-state-rendered-visible-window state)))
(if (or old-refresh-required
(not (ebox--scroll-rendered-visible-windows-equal-p
old-visible new-visible)))
(plist-put state :lazy-scroll-window-refresh-required t)
(ebox--plist-remove state :lazy-scroll-window-refresh-required))))
(defun ebox--scroll-state-materialize-lines (region-id state)
"Return STATE after materializing lazy scroll content for REGION-ID."
(if-let ((materialize (plist-get state :materialize-content-lines)))
(let* ((old-visible
(ebox--scroll-state-rendered-visible-window state))
(old-refresh-required
(plist-get state :lazy-scroll-window-refresh-required))
(buffer (ebox--scroll-state-buffer state))
(materialized
(if (and buffer (ebox--buffer-render-state buffer))
(ebox--with-buffer-render-context buffer
(ebox--with-validated-display-cache
(funcall materialize state region-id)))
(ebox--with-validated-display-cache
(funcall materialize state region-id))))
(content-lines (plist-get materialized :content-lines))
(rendered-lines (plist-get materialized :rendered-content-lines)))
(if (not content-lines)
state
(let ((ebox--defer-scroll-content-index t))
(setq state
(ebox--scroll-state-set-lines
state content-lines rendered-lines)))
(setq state (plist-put state :content-height
(plist-get materialized :content-height)))
(setq state (plist-put state :box
(or (plist-get materialized :box)
(plist-get state :box))))
(setq state (plist-put state :content-lines-complete-p t))
(setq state (ebox--plist-remove state :materialize-content-lines))
(setq state (ebox--plist-remove state
:native-reflow-materialize-p))
(dolist (key '(:native-reflow-target-prefix-p
:native-reflow-prefix-reset-p
:native-reflow-visible-offset
:native-reflow-visible-lines))
(setq state (ebox--plist-remove state key)))
(setq state (ebox--plist-remove state :render-content-prefix))
(setq state (ebox--plist-remove state :lazy-scroll-prefix-dirty))
(setq state
(ebox--scroll-state-set-visible-refresh-requirement
state old-visible old-refresh-required))
(ebox--scroll-cancel-idle-prefetch region-id)
(puthash region-id state ebox--scroll-global-state)
state))
state))
(defun ebox--scroll-state-ensure-prefix-lines
(region-id state required-lines &optional exact-prefix)
"Return STATE after rendering lazy scroll content through REQUIRED-LINES.
This preserves the lazy scroll contract: scroll input should extend only the
prefix needed by the target visible window, not materialize the whole source.
When EXACT-PREFIX is non-nil, do not render the extra completion sentinel;
interactive callers can publish an already-cached visible prefix without
probing source completion, while idle prefetch retains the sentinel."
(let* ((required-lines (max 0 (or required-lines 0)))
(old-visible
(ebox--scroll-state-rendered-visible-window state))
(old-refresh-required
(plist-get state :lazy-scroll-window-refresh-required))
(native-target-p
(plist-get state :native-reflow-target-prefix-p))
(native-reset-p
(plist-get state :native-reflow-prefix-reset-p))
(content-lines (plist-get state :content-lines))
(current-lines (length content-lines))
(prefix-dirty
(or (plist-get state :lazy-scroll-prefix-dirty)
native-target-p))
(render-prefix (plist-get state :render-content-prefix))
(extension-lines (max 0 (- required-lines current-lines)))
(interactive-step
(max 1
(or ebox-scroll-step 1)
(or ebox-wheel-smooth-scroll-lines-per-tick 1)))
(lookahead-lines
(if (<= extension-lines
interactive-step)
(max 0 ebox-scroll-lazy-prefix-lookahead-lines)
0))
(sentinel-lines
(if (and (> extension-lines 0) (not exact-prefix)) 1 0))
(render-target-lines
(+ required-lines sentinel-lines lookahead-lines)))
(if (or (and (not prefix-dirty)
(<= required-lines current-lines))
(plist-get state :content-lines-complete-p)
(not render-prefix))
state
(let* ((buffer (ebox--scroll-state-buffer state))
(render-state
(if native-reset-p
(plist-put (copy-sequence state)
:lazy-scroll-prefix-dirty t)
state))
(rendered
(if (and buffer (ebox--buffer-render-state buffer))
(ebox--with-buffer-render-context buffer
(ebox--with-validated-display-cache
(funcall render-prefix render-state region-id
render-target-lines)))
(ebox--with-validated-display-cache
(funcall render-prefix render-state region-id
render-target-lines))))
(prefix-lines (plist-get rendered :content-lines))
(rendered-lines (plist-get rendered :rendered-content-lines)))
(if (not prefix-lines)
state
(let ((ebox--defer-scroll-content-index t))
(setq state
(ebox--scroll-state-set-lines
state prefix-lines rendered-lines)))
(setq state
(plist-put state :content-height
(or (plist-get rendered :content-height)
(plist-get state :content-height))))
(setq state (plist-put state :box
(or (plist-get rendered :box)
(plist-get state :box))))
(setq state
(plist-put state :content-lines-complete-p
(plist-get rendered :complete)))
(setq state
(ebox--plist-remove state :native-reflow-prefix-reset-p))
(setq state (ebox--plist-remove
state :lazy-scroll-prefix-dirty))
(when (and native-target-p
(>= (length prefix-lines)
(+ (or (plist-get state :scroll-offset) 0)
(or (plist-get state :content-height) 0))))
(setq state
(ebox--plist-remove
state :native-reflow-target-prefix-p))
(setq state
(ebox--plist-remove
state :native-reflow-visible-offset))
(setq state
(ebox--plist-remove
state :native-reflow-visible-lines)))
;; A clean extension normally appends only offscreen lines. Avoid a
;; needless stop-and-refresh when the exact visible rendered slice is
;; unchanged, but retain the atomic refresh gate when rebuilt text or
;; redisplay properties differ.
(setq state
(ebox--scroll-state-set-visible-refresh-requirement
state old-visible old-refresh-required))
;; Native publication already exposed this exact visible window.
;; Property-list order can differ between the live patch and the
;; freshly rendered prefix while their text, faces and display
;; geometry are identical. Keep the bounded line-slide path when
;; the live marker window proves that visual identity.
(when (and native-target-p
(not (plist-get state
:native-reflow-target-prefix-p))
buffer
(ebox--scroll-state-window-markers-healthy-p
buffer region-id state))
(setq state
(ebox--plist-remove
state :lazy-scroll-window-refresh-required)))
(when (plist-get rendered :complete)
(setq state (ebox--plist-remove
state :materialize-content-lines))
(setq state (ebox--plist-remove
state :render-content-prefix))
(ebox--scroll-cancel-idle-prefetch region-id))
(puthash region-id state ebox--scroll-global-state)
state)))))
(defun ebox--next-content-span-change (pos limit)
"Return the next visible content span property change after POS."
(let ((next limit))
(dolist (prop '(ebox-content ebox-content-idx))
(let ((change (next-single-property-change pos prop nil limit)))
(when (and change (< change next))
(setq next change))))
next))
(defun ebox--scroll-state-with-content-marker-spans
(buffer state entries &optional defer-window-region-set)
"Return STATE with marker-backed content ENTRIES in BUFFER."
(let* ((max-idx (cl-loop for entry in entries
maximize (or (car entry) 0)))
(markers (make-vector (1+ (or max-idx 0)) nil))
ranges)
(with-current-buffer buffer
(dolist (entry entries)
(pcase-let ((`(,idx ,start . ,end) entry))
(when (and (integerp idx) start end)
(let ((start-marker (copy-marker start nil))
(end-marker (copy-marker end t)))
(aset markers idx (cons start-marker end-marker))
(push (cons start end) ranges))))))
(setq state (plist-put state :content-span-markers markers))
(if defer-window-region-set
(progn
(setq state (plist-put state :content-window-region-id-set nil))
(plist-put state :buffer buffer))
(ebox--scroll-state-put-window-region-id-set
state (ebox--scroll-window-region-id-set buffer ranges)))))
(defun ebox--scroll-store-content-marker-spans
(buffer region-id state entries &optional defer-window-region-set)
"Store marker-backed visible content ENTRIES for REGION-ID in BUFFER."
(let ((old-state state))
(setq state
(ebox--scroll-state-with-content-marker-spans
buffer (copy-sequence state) entries
defer-window-region-set))
(puthash region-id state ebox--scroll-global-state)
(ebox--scroll-clear-content-markers old-state)
(ebox--scroll-state-window-extents region-id state)))
(defun ebox--scroll-window-region-id-set-in-spans (spans)
"Return scroll viewport region ids marked inside buffer SPANS."
(let ((region-set (make-hash-table :test 'equal)))
(dolist (span spans)
(let ((pos (car span))
(end (cdr span)))
(while (< pos end)
(when-let ((region-id
(get-text-property pos 'ebox-scroll-window)))
(puthash region-id t region-set))
(setq pos
(or (next-single-property-change
pos 'ebox-scroll-window nil end)
end)))))
region-set))
(defun ebox--refresh-root-owner-scroll-windows (buffer results)
"Refresh lightweight scroll window markers after one root owner replacement.
Return non-nil when RESULTS cover BUFFER's root and every scroll viewport can
use the rendered-window fast path."
(when (and (= (length results) 1)
(equal (plist-get (car results) :owner-id)
(ebox--buffer-root-node-id buffer)))
(let ((region-set (ebox--buffer-region-id-set buffer))
(rendered-scroll-region-set
(with-current-buffer buffer
(ebox--scroll-window-region-id-set-in-spans
(plist-get (car results) :new-buffer-spans))))
(replacement-spans (plist-get (car results) :new-buffer-spans))
region-ids
windows
compatible)
(maphash
(lambda (region-id state)
(when (and (gethash region-id region-set)
(gethash region-id rendered-scroll-region-set)
(plist-get state :box))
(push region-id region-ids)))
ebox--scroll-global-state)
(setq region-ids (nreverse region-ids))
(if (null region-ids)
t
(with-current-buffer buffer
(if (ebox-buffer--region-role-span-table)
(ebox--refresh-box-extents-from-role-spans
region-ids replacement-spans)
(ebox--refresh-scroll-window-extents-from-render-markers
region-ids replacement-spans))
(setq compatible t)
(dolist (region-id region-ids)
(let* ((state (ebox--scroll-get-state region-id))
(box (plist-get state :box))
(height (plist-get state :content-height))
(extents (ebox--live-box-extents region-id))
(start (and extents
(marker-position (car extents))))
(end (and extents
(marker-position (cdr extents)))))
(if (and (ebox--scroll-chrome-free-rendered-window-p box)
(integerp height)
(> height 0)
start end (< start end)
(= (count-lines start end) height))
(push (list region-id state start end height) windows)
(setq compatible nil))))
(when compatible
(dolist (window windows)
(pcase-let ((`(,region-id ,state ,start ,end ,height) window))
(ebox--scroll-clear-content-markers state)
(setq state (plist-put state :content-span-markers nil))
(setq state
(plist-put state :content-window-region-id-set nil))
(ebox--scroll-store-rendered-window-extents
buffer region-id state start end height)))
(when-let ((render-state (ebox--buffer-render-state buffer)))
(plist-put render-state :scroll-region-ids region-ids))
t))))))
(defun ebox--face-visual-attributes (face)
"Return FACE's effective visual attributes under Emacs merge order.
Earlier entries of an anonymous face list take precedence, so for
every attribute keyword the first provider wins and the result is
independent of how contributors were merged or split. Named faces
cannot be attribute-merged without display context, so any face
containing a named entry compares by its raw representation instead."
(let ((entries (if (and (listp face) (not (keywordp (car-safe face))))
face
(list face)))
attributes named)
(dolist (entry entries)
(cond
((null entry) nil)
((and (listp entry) (keywordp (car-safe entry)))
(cl-loop for (key value) on entry by #'cddr
unless (assq key attributes)
do (push (cons key value) attributes)))
(t (setq named t))))
(if named
(list 'raw face)
(list 'visual
(sort attributes
(lambda (a b)
(string< (symbol-name (car a))
(symbol-name (car b)))))))))
(defun ebox--scroll-string-visual-signature (string)
"Return STRING's visual signature as pixel-extent glyph runs.
Each run is (START-PIXEL END-PIXEL TEXT DISPLAY VISUAL): equal runs
carry the same characters, the same display spec, the same effective
visual attributes, and the same pixel extent, which fully determines
their rendering. Faces compare by `ebox--face-visual-attributes'
rather than raw face shape: independent patch paths legitimately
produce different face representations for the same visible result,
and this signature's contract is visual equality. Pixel widths are
measured once per property run, never per character."
(let ((pixel 0)
(length (length string))
(position 0)
runs)
(cl-flet
((record (start end text display visual)
;; TEXT is nil for visually blank segments; blank extents
;; merge across property boundaries so equality does not
;; depend on how invisible properties segment identical
;; lines. Contiguous word segments with equal display and
;; visual attributes merge the same way.
(let ((run (car runs)))
(cond
((and run (null text) (null (nth 2 run))
(equal (nth 4 run) visual))
(setcar (cdr run) end))
((and run text (nth 2 run)
(equal (nth 3 run) display)
(equal (nth 4 run) visual))
(setcar (cdr run) end)
(setcar (cddr run) (concat (nth 2 run) text)))
(t (push (list start end text display visual) runs))))))
(while (< position length)
(let* ((next (or (next-property-change position string) length))
(face (get-text-property position 'face string))
(display (get-text-property position 'display string))
(visual (ebox--face-visual-attributes face))
(space-display-p (eq (car-safe display) 'space)))
(cond
(space-display-p
(let ((width (ebox--substring-pixel-width string position next)))
(record pixel (+ pixel width) nil nil visual)
(setq pixel (+ pixel width))))
(display
(let ((width (ebox--substring-pixel-width string position next)))
(record pixel (+ pixel width)
(substring-no-properties string position next)
display visual)
(setq pixel (+ pixel width))))
(t
(let ((cursor position))
(while (< cursor next)
(let ((blank (eq (aref string cursor) ?\s))
(segment-end cursor))
(while (and (< segment-end next)
(eq (eq (aref string segment-end) ?\s)
blank))
(setq segment-end (1+ segment-end)))
(let ((width (ebox--substring-pixel-width
string cursor segment-end)))
(record pixel (+ pixel width)
(unless blank
(substring-no-properties
string cursor segment-end))
nil visual)
(setq pixel (+ pixel width)))
(setq cursor segment-end))))))
(setq position next))))
(list pixel (nreverse runs))))
(defun ebox--scroll-lines-visually-equal-p (actual expected)
"Return non-nil when ACTUAL and EXPECTED line lists render identically.
Raw string/property equality is a sufficient fast path; only lines
whose representations diverge pay for pixel-measured signatures."
(and (= (length actual) (length expected))
(cl-every
(lambda (actual-line expected-line)
(or (equal-including-properties actual-line expected-line)
(equal (ebox--scroll-string-visual-signature actual-line)
(ebox--scroll-string-visual-signature expected-line))))
actual expected)))
(defun ebox--scroll-state-window-markers-healthy-p
(buffer region-id state)
"Return non-nil when STATE's live BUFFER window equals its cached slice."
(when-let* (((buffer-live-p buffer))
(box (plist-get state :box))
(height (plist-get state :content-height))
((integerp height))
((> height 0))
((eq (ebox--scroll-state-buffer state) buffer)))
(let* ((offset (or (plist-get state :scroll-offset) 0))
(native-offset (plist-get state :native-reflow-visible-offset))
(native-lines (plist-get state :native-reflow-visible-lines))
(chrome-free-p
(ebox--scroll-chrome-free-rendered-window-p box))
(marker-spans
(delq nil
(ebox--scroll-content-marker-spans
(plist-get state :content-span-markers))))
(contiguous-marker-window-p
(and chrome-free-p
(= (length marker-spans) height)
(ebox--scroll-content-window
(plist-put (copy-sequence state) :region-id region-id)
height)))
(lines
(if chrome-free-p
(or (plist-get state :rendered-content-lines)
(plist-get state :content-lines))
(plist-get state :content-lines)))
(visible
(cond
((and (equal native-offset offset)
(= (length native-lines) height))
(list offset native-lines))
(lines
(let ((end (min (length lines) (+ offset height))))
(and (<= (+ offset height) (length lines))
(list offset (seq-subseq lines offset end)))))))
(expected (and visible
(= (length (cadr visible)) height)
(cadr visible)))
(expected
(if (and expected
(not chrome-free-p))
(let ((decorated
(ebox--scroll-decorate-rendered-window-lines
expected box region-id)))
(if (= (length decorated) height)
decorated
expected))
expected)))
(and expected
(if (and chrome-free-p
(or (null marker-spans) contiguous-marker-window-p))
(when-let* ((extents (ebox--scroll-state-window-extents
region-id state))
(start (car extents))
(end (cdr extents))
((markerp start))
((markerp end))
((eq (marker-buffer start) buffer))
((eq (marker-buffer end) buffer))
(start-pos (marker-position start))
(end-pos (marker-position end))
((< start-pos end-pos))
(expected
(with-current-buffer buffer
(ebox--scroll-lines-with-live-wrapper-context
expected start-pos region-id))))
(and (= (with-current-buffer buffer
(count-lines start-pos end-pos))
height)
(ebox--scroll-lines-visually-equal-p
(with-current-buffer buffer
(ebox-string-lines
(buffer-substring start-pos end-pos)))
expected)))
(let ((spans marker-spans))
(and (= (length spans) height)
(cl-every
(lambda (span)
(when-let* (((consp span))
(start (car span))
(end (cdr span))
((markerp start))
((markerp end))
((eq (marker-buffer start) buffer))
((eq (marker-buffer end) buffer))
(start-pos (marker-position start))
(end-pos (marker-position end)))
(< start-pos end-pos)))
spans)
(ebox--scroll-lines-visually-equal-p
(with-current-buffer buffer
(mapcar
(lambda (span)
(buffer-substring
(marker-position (car span))
(marker-position (cdr span))))
spans))
expected))))))))
(defun ebox--publish-scoped-owner-scroll-windows
(buffer results source-node-ids
&optional pre-containing-region-ids rebuilt-region-ids
final-spans-by-result)
"Atomically publish scroll windows rebuilt by owner rerenders in BUFFER.
RESULTS are already-applied backend patch results and SOURCE-NODE-IDS are the
runtime nodes whose changes produced them. One buffer-wide marker rebuild is
performed only after every replacement is complete, so nested scroll windows
cannot invalidate markers installed for one another. `:covered-p' is non-nil
only when every source-containing state and every scroll state rebuilt inside
RESULTS has a verified live window from this same buffer generation."
(when (and (buffer-live-p buffer) results source-node-ids)
(let ((buffer-region-set (ebox--buffer-region-id-set buffer))
(rendered-region-set (make-hash-table :test 'equal))
rendered-region-ids
candidate-region-ids
published-region-ids
(affected-region-ids (copy-sequence pre-containing-region-ids)))
(with-current-buffer buffer
(dolist (result results)
(when (eq (plist-get result :op) 'owner-rerender)
(let ((spans
(or (and final-spans-by-result
(gethash result final-spans-by-result))
(plist-get result :new-buffer-spans))))
(maphash
(lambda (region-id _present)
(when (gethash region-id buffer-region-set)
(puthash region-id t rendered-region-set)))
(ebox--scroll-window-region-id-set-in-spans spans)))))
(maphash (lambda (region-id _present)
(push region-id rendered-region-ids))
rendered-region-set))
(dolist (region-id rendered-region-ids)
(cl-pushnew region-id affected-region-ids :test #'equal))
(dolist (region-id rebuilt-region-ids)
(when-let ((state (and (gethash region-id buffer-region-set)
(ebox--scroll-get-state region-id))))
(when (eq (ebox--scroll-state-buffer state) buffer)
(cl-pushnew region-id affected-region-ids :test #'equal)
(cl-pushnew region-id candidate-region-ids :test #'equal))))
(dolist (region-id rendered-region-ids)
(cl-pushnew region-id candidate-region-ids :test #'equal))
(when candidate-region-ids
;; Install every content marker generation together. This handles
;; chromed, chrome-free, root and nested scroll owners without a
;; per-state zero-delta rewrite.
(ebox--refresh-buffer-scroll-content-markers buffer)
(dolist (region-id candidate-region-ids)
(when-let ((state (ebox--scroll-get-state region-id)))
(when (ebox--scroll-state-window-markers-healthy-p
buffer region-id state)
(setq state
(ebox--plist-remove
state :lazy-scroll-window-refresh-required))
(puthash region-id state ebox--scroll-global-state)
(push region-id published-region-ids))))
(list :covered-p
(cl-every
(lambda (region-id)
(member region-id published-region-ids))
affected-region-ids)
:covered-region-ids (nreverse affected-region-ids)
:published-region-ids (nreverse published-region-ids))))))
(defun ebox--refresh-scroll-window-extents-from-render-markers
(region-ids spans)
"Refresh scroll REGION-IDS in SPANS from rendered window markers.
Root reflow invalidates the broad role index. Chrome-free scroll boxes mark
their exact visible window on the rendered string, so recovering those ranges
requires only one dedicated property scan rather than generic role inference."
(ebox--clear-box-extents-for-region-ids region-ids)
(when spans
(let ((region-set (ebox--region-id-set region-ids))
(ranges (make-hash-table :test 'equal)))
(dolist (span spans)
(let ((pos (car span))
(end (cdr span)))
(while (< pos end)
(let* ((region-id
(get-text-property pos 'ebox-scroll-window))
(next
(or (next-single-property-change
pos 'ebox-scroll-window nil end)
end)))
(when (and region-id (gethash region-id region-set))
(if-let ((range (gethash region-id ranges)))
(progn
(setcar range (min (car range) pos))
(setcdr range (max (cdr range) next)))
(puthash region-id (cons pos next) ranges)))
(setq pos (max next (1+ pos)))))))
(maphash
(lambda (region-id range)
(when (< (car range) (cdr range))
(ebox--set-box-extents region-id (car range) (cdr range))))
ranges))))
(defun ebox--refresh-buffer-scroll-content-markers (buffer)
"Refresh marker-backed scroll content spans for scroll regions in BUFFER."
(when (and (buffer-live-p buffer)
(> (hash-table-count ebox--scroll-global-state) 0))
(let ((spans-by-region (make-hash-table :test 'equal))
(owner-line-indexes (make-hash-table :test 'equal))
(seen (make-hash-table :test 'equal))
scroll-region-ids)
(cl-labels
((record (region-id idx start end)
(when (and region-id
(integerp idx)
(ebox--scroll-get-state region-id)
(< start end))
(puthash region-id t seen)
(puthash region-id
(cons (cons idx (cons start end))
(gethash region-id spans-by-region))
spans-by-region)))
(owner-ids-at (pos)
(let (ids)
(dolist (region-id (get-text-property
pos 'ebox-content-owners))
(cl-pushnew region-id ids :test #'equal))
(when-let ((region-id (get-text-property
pos 'ebox-content-owner)))
(cl-pushnew region-id ids :test #'equal))
ids))
(line-owner-spans (start end)
(let ((pos start)
spans)
(while (< pos end)
(let ((next (or (next-property-change pos nil end) end)))
(dolist (region-id (owner-ids-at pos))
(when (ebox--scroll-get-state region-id)
(let ((span (alist-get region-id spans nil nil
#'equal)))
(if span
(setcdr span next)
(push (cons region-id (cons pos next))
spans)))))
(setq pos (max next (1+ pos)))))
spans)))
(with-current-buffer buffer
(save-excursion
(goto-char (point-min))
(while (< (point) (point-max))
(let* ((region-id (get-text-property (point) 'ebox-content))
(idx (get-text-property (point) 'ebox-content-idx))
(next (ebox--next-content-span-change
(point) (point-max))))
(record region-id idx (point) next)
(goto-char (max next (1+ (point)))))))
(save-excursion
(goto-char (point-min))
(while (< (point) (point-max))
(let* ((line-start (point))
(line-end (line-end-position)))
(when (< line-start line-end)
(dolist (entry (line-owner-spans line-start line-end))
(let* ((region-id (car entry))
(span (cdr entry))
(idx (gethash region-id owner-line-indexes 0)))
(record region-id idx (car span) (cdr span))
(puthash region-id (1+ idx) owner-line-indexes))))
(forward-line 1))))))
(maphash
(lambda (region-id entries)
(when-let ((state (ebox--scroll-get-state region-id)))
(ebox--scroll-store-content-marker-spans
buffer region-id state (nreverse entries))))
spans-by-region)
(maphash
(lambda (region-id state)
(when (eq (ebox--scroll-state-buffer state) buffer)
(push region-id scroll-region-ids)
(unless (gethash region-id seen)
(ebox--scroll-clear-content-markers state)
(setq state (plist-put state :content-span-markers nil))
(setq state (plist-put state :content-window-region-id-set nil))
(setq state (plist-put state :buffer nil))
(puthash region-id state ebox--scroll-global-state))))
ebox--scroll-global-state)
(when-let ((render-state (ebox--buffer-render-state buffer)))
(plist-put render-state :scroll-region-ids
(nreverse scroll-region-ids))))))
(defun ebox--span-touches-range-p (span range)
"Return non-nil when SPAN overlaps RANGE or contains its empty insertion."
(if (= (car range) (cdr range))
(<= (car span) (car range) (cdr span))
(and (< (car span) (cdr range))
(> (cdr span) (car range)))))
(defun ebox--scroll-content-entries-in-range (region-id start end)
"Return visible content marker entries for REGION-ID inside START..END."
(let ((position start)
direct
owned
(owner-index 0))
(while (< position end)
(let* ((content (get-text-property position 'ebox-content))
(index (get-text-property position 'ebox-content-idx))
(next (ebox--next-content-span-change position end)))
(when (and (equal content region-id) (integerp index))
(push (cons index (cons position next)) direct))
(setq position (max next (1+ position)))))
(save-excursion
(goto-char start)
(while (< (point) end)
(let* ((line-start (point))
(line-end (min end (line-end-position)))
(position line-start)
owner-start owner-end)
(while (< position line-end)
(let* ((owners
(append
(get-text-property position 'ebox-content-owners)
(when-let ((owner
(get-text-property
position 'ebox-content-owner)))
(list owner))))
(next (or (next-property-change position nil line-end)
line-end)))
(when (member region-id owners)
(setq owner-start (or owner-start position)
owner-end next))
(setq position (max next (1+ position)))))
(when (and owner-start owner-end (< owner-start owner-end))
(push (cons owner-index (cons owner-start owner-end)) owned)
(cl-incf owner-index))
(forward-line 1))))
(nreverse (or direct owned))))
(defconst ebox--scroll-content-template-absent
'ebox--scroll-content-template-absent
"Sentinel returned when a scroll content template has no REGION-ID entry.")
(defun ebox--scroll-content-template-entries (template region-id start)
"Return REGION-ID scroll content entries from TEMPLATE at buffer START."
(when (and (hash-table-p template) (integerp start))
(let ((entries
(gethash region-id template
ebox--scroll-content-template-absent)))
(if (eq entries ebox--scroll-content-template-absent)
(list ebox--scroll-content-template-absent)
(mapcar
(lambda (entry)
(cons (car entry)
(cons (+ start (cadr entry))
(+ start (cddr entry)))))
entries)))))
(defun ebox--preflight-scroll-content-span-template
(buffer template start target-length)
"Validate TEMPLATE for BUFFER before prepared-root publication.
Return translated target entries keyed by scroll region id. Missing template
entries, malformed target offsets, and nonempty states with empty entries are
rejected before the visible buffer text changes."
(unless (and (buffer-live-p buffer)
(hash-table-p template)
(integerp start)
(integerp target-length)
(>= target-length 0))
(error "Invalid prepared scroll template"))
(let ((translated (make-hash-table :test 'equal))
(scroll-region-ids
(delete-dups
(append
(copy-sequence
(plist-get (ebox--buffer-render-state buffer)
:scroll-region-ids))
(cl-loop for region-id being the hash-keys of ebox--scroll-global-state
using (hash-values state)
when (eq (ebox--scroll-state-buffer state) buffer)
collect region-id)))))
(dolist (region-id scroll-region-ids)
(let ((entries
(gethash region-id template
ebox--scroll-content-template-absent))
(state (ebox--scroll-get-state region-id)))
(when (eq entries ebox--scroll-content-template-absent)
(error "Prepared scroll template missing region %S" region-id))
(unless state
(error "Prepared scroll template has no live state for %S"
region-id))
(when (and (null entries)
(> (or (plist-get state :content-height) 0) 0))
(error "Prepared scroll template is empty for nonempty region %S"
region-id))
(puthash
region-id
(mapcar
(lambda (entry)
(let ((index (car entry))
(entry-start (cadr entry))
(entry-end (cddr entry)))
(unless (and (integerp index)
(>= index 0)
(integerp entry-start)
(integerp entry-end)
(<= 0 entry-start entry-end target-length)
(< entry-start entry-end))
(error "Prepared scroll template has invalid target offsets"))
(cons index
(cons (+ start entry-start)
(+ start entry-end)))))
entries)
translated)))
(list :entries-by-region translated
:scroll-region-ids scroll-region-ids)))
(defun ebox--refresh-patched-scroll-markers
(buffer affected-region-ids published-spans
&optional scroll-content-template root-start)
"Refresh only scroll windows touched by an exact root patch in BUFFER.
Return a plist whose `:complete-p' value proves every affected live scroll
state received one current marker generation.
SCROLL-CONTENT-TEMPLATE contains target-side scroll spans relative to
ROOT-START, and avoids live extent scans for prepared root publications."
(when (buffer-live-p buffer)
(let ((candidate-set (make-hash-table :test 'equal))
candidates staged staged-publications published
(complete-p t))
(dolist (region-id affected-region-ids)
(when (ebox--scroll-get-state region-id)
(puthash region-id t candidate-set)))
(dolist (region-id
(plist-get (ebox--buffer-render-state buffer)
:scroll-region-ids))
(let* ((template-entries
(ebox--scroll-content-template-entries
scroll-content-template region-id root-start))
(template-present-p
(not (eq (car template-entries)
ebox--scroll-content-template-absent)))
extents start end)
(unless template-present-p
(setq extents
(with-current-buffer buffer
(ebox--live-box-extents region-id))
start (and extents (marker-position (car extents)))
end (and extents (marker-position (cdr extents)))))
;; Exact root patches already bound the publication work. If their
;; role delta is empty, retain correctness for scroll roots whose
;; extents do not expose the changed interior by refreshing the
;; small render-state scroll set, never by scanning the page.
(when (or (null affected-region-ids)
template-present-p
(and start end (< start end)
(cl-some
(lambda (span)
(ebox--span-touches-range-p
(cons start end) span))
published-spans)))
(puthash region-id t candidate-set))))
(maphash (lambda (region-id _present)
(push region-id candidates))
candidate-set)
(with-current-buffer buffer
(dolist (region-id candidates)
(let* ((state (ebox--scroll-get-state region-id))
(template-entries
(ebox--scroll-content-template-entries
scroll-content-template region-id root-start))
(template-present-p
(not (eq (car template-entries)
ebox--scroll-content-template-absent)))
extents start end entries)
(if (not state)
(setq complete-p nil)
(unless template-present-p
(setq extents (ebox--live-box-extents region-id)
start (and extents (marker-position (car extents)))
end (and extents (marker-position (cdr extents)))))
(setq entries
(if template-present-p
template-entries
(and start end (< start end)
(ebox--scroll-content-entries-in-range
region-id start end))))
(if (or entries
(and template-present-p
(= (or (plist-get state :content-height) 0)
0)))
(push (list region-id state entries) staged)
(setq complete-p nil)))))
(when complete-p
(dolist (prepared (nreverse staged))
(pcase-let ((`(,region-id ,state ,entries) prepared))
(let* ((render-state (ebox--buffer-render-state buffer))
(old-state state)
(state (copy-sequence state))
(box (plist-get state :box))
(viewport-width
(plist-get render-state :viewport-width))
(viewport-height
(plist-get render-state :viewport-height))
(prefix-viewport-width
(and box viewport-width
(let ((ebox-viewport-width viewport-width))
(or (ebox--wrapper-content-viewport-pixel box)
viewport-width))))
(content-height (plist-get state :content-height))
(scroll-offset
(or (plist-get state :scroll-offset) 0))
(source
(and box
(ebox--scroll-window-stack-source
(ebox--box-content-node box))))
(visible-lines
(and (integerp content-height)
(= (length entries) content-height)
(cl-loop
for entry in (sort (copy-sequence entries)
(lambda (left right)
(< (car left)
(car right))))
for expected-index from 0
when (= (car entry) expected-index)
collect
(buffer-substring
(cadr entry) (cddr entry))))))
;; The patch contains the complete visible target window but
;; no hidden target page. Drop the old-width line cache,
;; retain only this exact visible proof, and rebuild the new
;; prefix incrementally. This keeps publication proportional
;; to the patch and prevents first scroll input from paying a
;; full-page materialization.
(when (and box source prefix-viewport-width viewport-height
(= (length visible-lines) content-height))
(setq state (plist-put state :content-lines nil))
(setq state
(ebox--plist-remove state
:rendered-content-lines))
(dolist (key '(:content-region-id-set
:region-line-bounds-index
:region-line-span-index
:region-line-span-index-deferred
:rendered-region-line-span-index
:rendered-region-line-span-index-deferred
:region-line-span-hints))
(setq state (ebox--plist-remove state key)))
(setq state
(plist-put
state :render-content-prefix
(ebox--scroll-window-prefix-renderer
box source prefix-viewport-width
viewport-height)))
(setq state
(plist-put
state :scroll-window-producer-spec
(ebox--scroll-window-producer-spec
prefix-viewport-width viewport-width
viewport-height)))
(setq state
(plist-put
state :materialize-content-lines
(ebox--scroll-window-materializer
box viewport-width viewport-height)))
(setq state
(plist-put state :content-lines-complete-p nil))
(setq state
(plist-put state
:native-reflow-target-prefix-p t))
(setq state
(plist-put state
:native-reflow-prefix-reset-p t))
(setq state
(plist-put state :native-reflow-visible-offset
scroll-offset))
(setq state
(plist-put state :native-reflow-visible-lines
visible-lines))
(setq state
(ebox--plist-remove
state :native-reflow-materialize-p)))
(push
(list region-id old-state
(ebox--scroll-state-with-content-marker-spans
buffer state entries))
staged-publications)
(push region-id published)))))
(let (swapped)
(condition-case err
(progn
(dolist (publication (nreverse staged-publications))
(pcase-let ((`(,region-id ,old-state ,new-state)
publication))
(puthash region-id new-state ebox--scroll-global-state)
(push (list region-id old-state new-state) swapped)))
(dolist (publication swapped)
(pcase-let ((`(,region-id ,_old-state ,_new-state)
publication))
(ebox--scroll-schedule-idle-prefetch region-id)))
(dolist (publication swapped)
(pcase-let ((`(,_region-id ,old-state ,_new-state)
publication))
(ebox--scroll-clear-content-markers old-state))))
(error
(dolist (publication swapped)
(pcase-let ((`(,region-id ,old-state ,new-state)
publication))
(puthash region-id old-state ebox--scroll-global-state)
(ebox--scroll-clear-content-markers new-state)))
(signal (car err) (cdr err))))))
(list :complete-p complete-p
:affected-region-ids (nreverse candidates)
:published-region-ids (nreverse published)))))
(defun ebox--next-string-content-span-change (position string limit)
"Return next content property change after POSITION in STRING."
(let ((next limit))
(dolist (property '(ebox-content ebox-content-idx))
(let ((change (next-single-property-change
position property string limit)))
(when (and change (< change next))
(setq next change))))
next))
(defun ebox--next-string-content-owner-change (position string limit)
"Return next wrapper-owner property change after POSITION in STRING."
(let ((next limit))
(dolist (property '(ebox-content-owner ebox-content-owners))
(let ((change (next-single-property-change
position property string limit)))
(when (and change (< change next))
(setq next change))))
next))
(defun ebox--build-scroll-content-span-template (string)
"Build scroll content marker offsets from rendered STRING."
(let ((table (make-hash-table :test 'equal))
(owner-line-indexes (make-hash-table :test 'equal))
(limit (length string)))
(cl-labels
((record (region-id index start end)
(when (and region-id
(gethash region-id ebox--scroll-global-state)
(integerp index)
(< start end))
(puthash region-id
(cons (cons index (cons start end))
(gethash region-id table))
table)))
(owner-ids-at (position)
(let (ids)
(dolist (region-id
(get-text-property position 'ebox-content-owners string))
(when (gethash region-id ebox--scroll-global-state)
(cl-pushnew region-id ids :test #'equal)))
(when-let ((region-id
(get-text-property
position 'ebox-content-owner string)))
(when (gethash region-id ebox--scroll-global-state)
(cl-pushnew region-id ids :test #'equal)))
ids))
(line-owner-spans (start end)
(let ((position start)
spans)
(while (< position end)
(let ((next (ebox--next-string-content-owner-change
position string end)))
(dolist (region-id (owner-ids-at position))
(let ((span (alist-get region-id spans nil nil #'equal)))
(if span
(setcdr span next)
(push (cons region-id (cons position next)) spans))))
(setq position (max next (1+ position)))))
spans)))
(let ((position 0))
(while (< position limit)
(let* ((region-id
(get-text-property position 'ebox-content string))
(index
(get-text-property position 'ebox-content-idx string))
(next
(ebox--next-string-content-span-change
position string limit)))
(record region-id index position next)
(setq position (max next (1+ position))))))
(let ((line-start 0))
(while (< line-start limit)
(let ((line-end (or (string-search "\n" string line-start) limit)))
(when (< line-start line-end)
(dolist (entry (line-owner-spans line-start line-end))
(let* ((region-id (car entry))
(span (cdr entry))
(index (gethash region-id owner-line-indexes 0)))
(record region-id index (car span) (cdr span))
(puthash region-id (1+ index) owner-line-indexes))))
(setq line-start (if (< line-end limit) (1+ line-end) limit))))))
(maphash (lambda (region-id entries)
(puthash region-id (nreverse entries) table))
table)
table))
(defun ebox--install-buffer-scroll-content-span-template
(buffer template start)
"Install scroll marker TEMPLATE in BUFFER relative to START."
(when (and (buffer-live-p buffer) (hash-table-p template))
(let ((region-set (ebox--buffer-region-id-set buffer))
(seen (make-hash-table :test 'equal))
scroll-region-ids)
(maphash
(lambda (region-id entries)
(when-let ((state (and (gethash region-id region-set)
(ebox--scroll-get-state region-id))))
(puthash region-id t seen)
(ebox--scroll-store-content-marker-spans
buffer region-id state
(mapcar (lambda (entry)
(cons (car entry)
(cons (+ start (cadr entry))
(+ start (cddr entry)))))
entries)
t)))
template)
(maphash
(lambda (region-id state)
(when (eq (ebox--scroll-state-buffer state) buffer)
(push region-id scroll-region-ids)
(unless (gethash region-id seen)
(ebox--scroll-clear-content-markers state)
(setq state (plist-put state :content-span-markers nil))
(setq state (plist-put state :content-window-region-id-set nil))
(setq state (plist-put state :buffer nil))
(puthash region-id state ebox--scroll-global-state))))
ebox--scroll-global-state)
(when-let ((render-state (ebox--buffer-render-state buffer)))
(plist-put render-state :scroll-region-ids
(nreverse scroll-region-ids)))
t)))
(defvar ebox--rendered-root-metadata-table
(make-hash-table :test 'eq :weakness 'key)
"Weak rendered-string map for root replacement index templates.")
(defun ebox--string-foreign-region-in-range-p
(rendered start end region-id)
"Return non-nil when RENDERED START..END crosses REGION-ID ownership."
(let ((position start)
found)
(while (and (< position end) (not found))
(unless (eq (aref rendered position) ?\n)
(let ((region-ids (ebox--string-region-ids-at rendered position))
(owner-stack
(get-text-property
position 'ebox-content-owners rendered)))
(when (cl-some
(lambda (owner)
(and (not (equal owner region-id))
(not (member region-id owner-stack))))
region-ids)
(setq found t))))
(setq position (1+ position)))
found))
(defun ebox--build-box-extent-template (rendered)
"Build safe point-min-based numeric box extents from RENDERED once."
(let ((table (make-hash-table :test 'equal))
(interleaved (make-hash-table :test 'equal))
(position 0)
(limit (length rendered)))
(while (< position limit)
(let* ((next (or (next-property-change position rendered limit) limit))
(start (1+ position))
(end (1+ next))
(region-ids
(delete-dups
(append
(get-text-property position 'ebox-content-owners rendered)
(when-let ((owner (get-text-property
position 'ebox-content-owner rendered)))
(list owner))
(when-let ((content (get-text-property
position 'ebox-content rendered)))
(list content))
(delq nil
(mapcar
(lambda (entry)
(get-text-property position (cdr entry) rendered))
ebox-region-types))))))
(dolist (region-id region-ids)
(if-let ((span (gethash region-id table)))
(progn
(when (and (> start (cdr span))
(ebox--string-foreign-region-in-range-p
rendered (1- (cdr span)) position region-id))
(puthash region-id t interleaved))
(setcar span (min (car span) start))
(setcdr span (max (cdr span) end)))
(puthash region-id (cons start end) table)))
(setq position (max next (1+ position)))))
(maphash (lambda (region-id _)
(remhash region-id table))
interleaved)
table))
(defun ebox--register-rendered-root-metadata (rendered prepared)
"Register Rust PREPARED root indexes for RENDERED.
The native tape uses ordinary hash tables. Add the private template marker
only after the tape has been validated, then update any stable lightweight
descriptor already retained by a prepared patch result."
(unless (and (stringp rendered)
(plist-get prepared :prepared-p)
(hash-table-p (plist-get prepared :role-span-template))
(hash-table-p (plist-get prepared :box-extent-template))
(hash-table-p
(plist-get prepared :scroll-content-span-template)))
(error "Invalid prepared rendered-root metadata"))
(let* ((role-template (plist-get prepared :role-span-template))
(metadata
(or (gethash rendered ebox--rendered-root-metadata-table)
(list :prepared-p nil
:role-span-template nil
:box-extent-template nil
:scroll-content-span-template nil
:scroll-window-p nil))))
(puthash ebox-buffer--role-span-table-template-key t role-template)
(plist-put metadata :role-span-template role-template)
(plist-put metadata :box-extent-template
(plist-get prepared :box-extent-template))
(plist-put metadata :scroll-content-span-template
(plist-get prepared :scroll-content-span-template))
(plist-put metadata :scroll-window-p
(and (plist-get prepared :scroll-window-p) t))
(plist-put metadata :prepared-p t)
(puthash rendered metadata ebox--rendered-root-metadata-table)
metadata))
(defun ebox--rendered-root-metadata (rendered &optional lightweight-only)
"Return reusable root replacement metadata for RENDERED output.
When LIGHTWEIGHT-ONLY is non-nil, record only whether RENDERED contains a
scroll window. A later offscreen prewarm or an incompatible visible scroll
root may upgrade the same metadata object with complete role and scroll span
templates. This keeps ordinary cold-width publication off the full-output
property-scan path while preserving prepared indexes where they are needed."
(let ((metadata
(gethash rendered ebox--rendered-root-metadata-table)))
(unless metadata
(setq metadata
(list :prepared-p nil
:role-span-template nil
:box-extent-template nil
:scroll-content-span-template nil
:scroll-window-p
(not
(null
(text-property-not-all
0 (length rendered)
'ebox-scroll-window nil rendered)))))
(puthash rendered metadata ebox--rendered-root-metadata-table))
(unless (or lightweight-only
(plist-get metadata :prepared-p))
;; Keep the plist object stable: patch results holding the lightweight
;; descriptor observe this in-place upgrade without retaining RENDERED.
(plist-put metadata :role-span-template
(ebox-buffer--build-region-role-span-template rendered))
(plist-put metadata :box-extent-template
(ebox--build-box-extent-template rendered))
(plist-put metadata :scroll-content-span-template
(ebox--build-scroll-content-span-template rendered))
(plist-put metadata :prepared-p t))
metadata))
(defconst ebox--scroll-state-span-patch-keys
'(:content :text-align :vertical-align :wrap-mode :scroll-offset)
"Local keys that can patch cached scroll content lines directly.")
(defun ebox--scroll-state-span-patch-keys-p (changed-keys)
"Return non-nil when CHANGED-KEYS are safe for scroll-state line patching."
(and changed-keys
(cl-every
(lambda (key)
(or (memq key ebox--scroll-state-span-patch-keys)
(and (memq :content changed-keys)
(memq key ebox--paint-style-signature-keys))))
changed-keys)))
(defun ebox--scroll-state-owned-by-buffer-p (buffer state)
"Return non-nil when scroll STATE belongs to BUFFER's runtime tree.
An explicit buffer association is authoritative. A render-only pass can
temporarily replace a live state with an ownerless state for the exact same
runtime box, so accept that state only when BUFFER indexes the identical box
object. Unrelated ownerless and foreign-buffer states remain isolated."
(let ((state-buffer (ebox--scroll-state-buffer state)))
(or (eq state-buffer buffer)
(and (null state-buffer)
(when-let* ((box (plist-get state :box))
(box-id (ebox--ensure-node-id box)))
(eq (ebox--buffer-runtime-node buffer box-id) box))))))
(defun ebox--scroll-state-covers-node-p (buffer state node)
"Return non-nil when lazy scroll STATE owns NODE in BUFFER."
(and (ebox--scroll-state-owned-by-buffer-p buffer state)
(when-let* ((box (plist-get state :box))
(box-id (ebox--ensure-node-id box))
(node-id (plist-get node :node-id)))
(or (equal box-id node-id)
(ebox--runtime-ancestor-id-p buffer box-id node-id)))))
(defun ebox--scroll-state-region-ids-containing-node-ids
(buffer node-ids)
"Return BUFFER scroll region ids containing any runtime NODE-IDS."
(let ((buffer-region-ids
(plist-get (ebox--buffer-render-state buffer) :scroll-region-ids))
region-ids)
(dolist (node-id node-ids)
(when-let ((node (ebox--buffer-runtime-node buffer node-id)))
(dolist (region-id buffer-region-ids)
(when-let ((state (ebox--scroll-get-state region-id)))
(when (ebox--scroll-state-covers-node-p buffer state node)
(cl-pushnew region-id region-ids :test #'equal))))))
(nreverse region-ids)))
(defun ebox--region-ids-visible-via-role-table-p (region-ids)
"Return non-nil when REGION-IDS have any visible role span in current buffer."
(when-let ((table (ebox-buffer--region-role-span-table)))
(catch 'visible
(dolist (region-id region-ids)
(dolist (entry ebox-region-types)
(when (cl-some
(lambda (span)
(ebox-buffer--current-region-role-span-p
span region-id (car entry)))
(gethash (ebox-buffer--region-role-key
region-id (car entry))
table))
(throw 'visible t))))
nil)))
(defun ebox--region-ids-role-table-status (table region-ids)
"Return `current', `stale', or nil for REGION-IDS entries in TABLE."
(let (indexed)
(catch 'status
(dolist (region-id region-ids)
(dolist (entry ebox-region-types)
(dolist (span (gethash (ebox-buffer--region-role-key
region-id (car entry))
table))
(setq indexed t)
(unless (ebox-buffer--current-region-role-span-p
span region-id (car entry))
(throw 'status 'stale)))))
(and indexed 'current))))
(defun ebox--region-ids-visible-by-property-scan-p (region-ids)
"Return non-nil when REGION-IDS appear in current buffer text properties."
(catch 'visible
(dolist (region-id region-ids)
(dolist (entry ebox-region-types)
(goto-char (point-min))
(when (text-property-search-forward (cdr entry) region-id t)
(throw 'visible t))))
nil))
(defun ebox--region-ids-visible-in-buffer-p (buffer region-ids)
"Return non-nil when REGION-IDS currently have visible spans in BUFFER."
(and buffer
region-ids
(buffer-live-p buffer)
(with-current-buffer buffer
(or (ebox--region-ids-visible-via-role-table-p region-ids)
(unless (ebox-buffer--region-role-span-table)
(save-excursion
(ebox--region-ids-visible-by-property-scan-p
region-ids)))))))
(defun ebox--runtime-node-own-region-ids (node)
"Return NODE's own wrapper region ids without descending into children."
(when (listp node)
(pcase (plist-get node :ebox-type)
('box
(when-let ((region-id (ebox-get node :region-id)))
(list region-id)))
('flex
(when-let* ((box (plist-get node :box))
(region-id (ebox-get box :region-id)))
(list region-id))))))
(defun ebox--scroll-state-node-visible-p (buffer state node)
"Return non-nil when NODE occupies STATE's current visible window.
When an empty fixed box has no own text-property span, walk only through its
local wrapper ancestors. Stop before the scroll owner's direct content
wrapper so one visible row cannot make every hidden descendant look visible."
(when-let* ((scroll-box (plist-get state :box))
(scroll-node-id (ebox--ensure-node-id scroll-box))
(node-id (plist-get node :node-id)))
(let ((candidate-id node-id)
(first t))
(catch 'visible
(while candidate-id
(let* ((candidate
(ebox--buffer-runtime-node buffer candidate-id))
(parent-id
(ebox--runtime-parent-id buffer candidate-id)))
(when (or first (not (equal parent-id scroll-node-id)))
(when-let ((region-ids
(ebox--runtime-node-own-region-ids candidate)))
(when (or
(ebox--region-ids-visible-in-buffer-p
buffer region-ids)
(ebox--scroll-state-region-visible-in-window-p
state region-ids)
(ebox--scroll-state-region-visible-in-content-range-p
state region-ids))
(throw 'visible t))))
(when (or (equal candidate-id scroll-node-id)
(equal parent-id scroll-node-id))
(setq candidate-id nil))
(when candidate-id
(setq candidate-id parent-id
first nil))))
nil))))
(defun ebox--node-visible-in-buffer-p (buffer node)
"Return non-nil when NODE is represented in BUFFER's visible scroll windows."
(let ((region-ids (and node (ebox--node-all-region-ids node)))
containing-states)
(or (ebox--region-ids-visible-in-buffer-p buffer region-ids)
(progn
(maphash
(lambda (_scroll-region-id state)
(when (ebox--scroll-state-covers-node-p buffer state node)
(push state containing-states)))
ebox--scroll-global-state)
(and containing-states
(cl-every
(lambda (state)
(ebox--scroll-state-node-visible-p buffer state node))
containing-states))))))
(defun ebox--ensure-visible-node-runtime-indexes
(buffer node &optional primary-region-id)
"Refresh missing or stale visible role spans for NODE in BUFFER.
A missing table uses a targeted property search before rebuilding the small
rendered viewport. A present table is authoritative unless requested entries
exist but no longer validate, in which case rebuild it without a property scan."
(when (and (buffer-live-p buffer) node)
(let ((region-ids (if primary-region-id
(list primary-region-id)
(ebox--node-all-region-ids node))))
(with-current-buffer buffer
(let ((table (ebox-buffer--region-role-span-table)))
(if table
(when (eq (ebox--region-ids-role-table-status
table region-ids)
'stale)
(ebox-buffer-refresh-region-role-spans buffer)
t)
(when (save-excursion
(ebox--region-ids-visible-by-property-scan-p region-ids))
(ebox-buffer-refresh-region-role-spans buffer)
t)))))))
(defun ebox--mark-lazy-scroll-covered-hidden-states
(buffer node region-ids)
"Mark lazy scroll states covering hidden NODE/REGION-IDS as dirty.
Return the number of marked states. This preserves deferred hidden-update
performance while preventing later prefix extension from replaying stale cached
lines."
(let ((count 0))
(unless (ebox--node-visible-in-buffer-p buffer node)
(maphash
(lambda (scroll-region-id state)
(when (and (plist-get state :materialize-content-lines)
(ebox--scroll-state-covers-node-p buffer state node))
(let* ((content-lines (plist-get state :content-lines))
(cached-spans
(and content-lines
(ebox--scroll-hot-content-line-spans
state region-ids))))
(when (and (not cached-spans)
(not (ebox--scroll-state-region-visible-in-window-p
state region-ids)))
(setq state (plist-put state :lazy-scroll-prefix-dirty t))
(puthash scroll-region-id state ebox--scroll-global-state)
(cl-incf count)))))
ebox--scroll-global-state))
count))
(defun ebox--try-lazy-scroll-deferred-patch
(buffer region-id node changed-keys)
"Record a hidden lazy scroll update without materializing its scroll source."
(when (and buffer node changed-keys)
(let ((region-ids (ebox--node-all-region-ids node)))
(when (> (ebox--mark-lazy-scroll-covered-hidden-states
buffer node region-ids)
0)
(ebox--update-report
region-id 'span-patch
:dirty-count 1
:patch-count 0
:patch-ops nil
:owner-id (plist-get node :node-id)
:owner-ids (list (plist-get node :node-id))
:owner-type (plist-get node :ebox-type)
:span-count 0
:scroll-state-patch t
:lazy-scroll-deferred-patch t
:dirty-kinds '(geometry)
:dirty-keys changed-keys)))))
(defun ebox--string-region-role-ids-at (string pos)
"Return ebox role/id pairs at POS in STRING."
(let (role-ids)
(dolist (entry ebox-region-types)
(when-let ((region-id (get-text-property pos (cdr entry) string)))
(push (cons (car entry) region-id) role-ids)))
role-ids))
(defun ebox--string-region-ids-at (string pos)
"Return all ebox region ids present at POS in STRING."
(let (ids)
(dolist (region-id (get-text-property pos 'ebox-content-owners string))
(cl-pushnew region-id ids :test #'equal))
(dolist (entry ebox-region-types)
(when-let ((region-id (get-text-property pos (cdr entry) string)))
(cl-pushnew region-id ids :test #'equal)))
ids))
(defun ebox--string-next-region-property-change (string pos limit)
"Return next ebox region property change in STRING after POS, capped at LIMIT."
(let ((next limit))
(dolist (entry ebox-region-types)
(let ((change (next-single-property-change
pos (cdr entry) string limit)))
(when (and change (< change next))
(setq next change))))
next))
(defun ebox--string-region-role-ids-compatible-p (role-ids region-set)
"Return non-nil when ROLE-IDS do not cross foreign direct content owners."
(cl-every (lambda (role-id)
(or (not (eq (car role-id) 'content))
(gethash (cdr role-id) region-set)))
role-ids))
(defun ebox--scroll-line-region-span (line region-set)
"Return LINE span owned by REGION-SET, or nil when absent/incompatible."
(let ((pos 0)
(limit (length line))
span-start span-end patchable seen)
(setq patchable t)
(while (and patchable (< pos limit))
(let* ((next (ebox--string-next-region-property-change line pos limit))
(ids (ebox--string-region-ids-at line pos)))
(when (cl-some (lambda (region-id) (gethash region-id region-set))
ids)
(setq seen t)
(unless span-start
(setq span-start pos))
(setq span-end next)
(unless (ebox--string-region-role-ids-compatible-p
(ebox--string-region-role-ids-at line pos)
region-set)
(setq patchable nil)))
(setq pos (max next (1+ pos)))))
(and seen patchable span-start span-end (cons span-start span-end))))
(defun ebox--scroll-line-region-span-for-ids (line region-ids)
"Return a conservative owned span for REGION-IDS in LINE.
This lookup searches each role directly in the string property intervals. It
is optimized for validating a few known rendered-line hints and returns nil
when foreign direct content makes the combined span ambiguous."
(let ((limit (length line))
(region-set (ebox--region-id-set region-ids))
span-start span-end)
(dolist (region-id region-ids)
(dolist (entry ebox-region-types)
(let ((property (cdr entry))
(pos 0)
found)
(while (and (< pos limit)
(setq found
(text-property-any
pos limit property region-id line)))
(let ((next (or (next-single-property-change
found property line limit)
limit)))
(setq span-start (if span-start
(min span-start found)
found)
span-end (if span-end
(max span-end next)
next)
pos (max next (1+ found))))))))
(let ((pos 0))
(while (< pos limit)
(let* ((owners (get-text-property
pos 'ebox-content-owners line))
(next (or (next-single-property-change
pos 'ebox-content-owners line limit)
limit)))
(when (cl-some (lambda (region-id)
(member region-id owners))
region-ids)
(setq span-start (if span-start (min span-start pos) pos)
span-end (if span-end (max span-end next) next)))
(setq pos (max next (1+ pos))))))
(when (and span-start span-end)
(let ((pos span-start)
(compatible t))
(while (and compatible (< pos span-end))
(let* ((content-id
(get-text-property pos 'ebox-content line))
(next (or (next-single-property-change
pos 'ebox-content line span-end)
span-end)))
(when (and content-id
(not (gethash content-id region-set)))
(setq compatible nil))
(setq pos (max next (1+ pos)))))
(and compatible (cons span-start span-end))))))
(defun ebox--scroll-line-region-present-p (line region-ids)
"Return non-nil when LINE has any direct role for REGION-IDS."
(let ((limit (length line)))
(and (> limit 0)
(cl-some
(lambda (region-id)
(cl-some
(lambda (entry)
(text-property-any 0 limit (cdr entry) region-id line))
ebox-region-types))
region-ids))))
(defun ebox--scroll-state-region-line-spans (lines region-ids)
"Return cached line spans in LINES owned by REGION-IDS."
(let ((region-set (ebox--region-id-set region-ids))
spans)
(cl-loop for line in lines
for idx from 0
do (when (ebox--scroll-line-region-present-p line region-ids)
(when-let ((span (ebox--scroll-line-region-span
line region-set)))
(push (cons idx span) spans))))
(nreverse spans)))
(defun ebox--scroll-line-region-ids (line)
"Return all ebox region ids present in LINE."
(let ((pos 0)
(limit (length line))
ids)
(while (< pos limit)
(dolist (region-id (ebox--string-region-ids-at line pos))
(cl-pushnew region-id ids :test #'equal))
(setq pos (max (ebox--string-next-region-property-change line pos limit)
(1+ pos))))
(nreverse ids)))
(defun ebox--scroll-line-content-region-ids (line)
"Return LINE's content-topology region ids through the cheap owner fields.
Decoration-only legacy lines fall back to the complete role scanner."
(let ((pos 0)
(limit (length line))
ids
topology-seen)
(while (< pos limit)
(dolist (region-id (get-text-property pos 'ebox-content-owners line))
(setq topology-seen t)
(cl-pushnew region-id ids :test #'equal))
(dolist (property '(ebox-content ebox-content-owner))
(when-let ((region-id (get-text-property pos property line)))
(setq topology-seen t)
(cl-pushnew region-id ids :test #'equal)))
(setq pos
(max
(1+ pos)
(min
(or (next-single-property-change
pos 'ebox-content-owners line limit)
limit)
(or (next-single-property-change
pos 'ebox-content line limit)
limit)
(or (next-single-property-change
pos 'ebox-content-owner line limit)
limit)))))
(if topology-seen
(nreverse ids)
(ebox--scroll-line-region-ids line))))
(defun ebox--scroll-line-direct-content-region-ids (line)
"Return direct `ebox-content' region ids in LINE.
Ancestor content-owner ids are intentionally excluded: adapter containers such
as flex wrappers do not own a concrete line span, and selector planning already
uses their rendered descendants."
(let ((pos 0)
(limit (length line))
ids)
(while (< pos limit)
(when-let ((region-id (get-text-property pos 'ebox-content line)))
(cl-pushnew region-id ids :test #'equal))
(setq pos
(max (1+ pos)
(or (next-single-property-change
pos 'ebox-content line limit)
limit))))
(nreverse ids)))
(defun ebox--scroll-line-bounds-index-add (index line-index line)
"Add LINE at LINE-INDEX to lightweight region bounds INDEX."
(dolist (region-id (ebox--scroll-line-direct-content-region-ids line))
(if-let ((bounds (gethash region-id index)))
(setcdr bounds line-index)
(puthash region-id (cons line-index line-index) index)))
index)
(defun ebox--scroll-build-region-line-bounds-index (lines)
"Return a lightweight region id to inclusive line bounds index for LINES."
(let ((index (make-hash-table :test 'equal)))
(cl-loop for line in lines
for line-index from 0
do (ebox--scroll-line-bounds-index-add
index line-index line))
index))
(defun ebox--scroll-lines-prefix-equal-p (prefix lines)
"Return non-nil when PREFIX is an exact property-preserving prefix of LINES."
(and (<= (length prefix) (length lines))
(cl-loop for old-line in prefix
for new-line in lines
always (equal-including-properties old-line new-line))))
(defun ebox--scroll-update-region-line-bounds-index
(state old-lines new-lines)
"Return STATE's lightweight bounds index updated for NEW-LINES."
(let ((index (plist-get state :region-line-bounds-index)))
(if (and index
old-lines
(ebox--scroll-lines-prefix-equal-p old-lines new-lines))
(progn
(cl-loop for line in (nthcdr (length old-lines) new-lines)
for line-index from (length old-lines)
do (ebox--scroll-line-bounds-index-add
index line-index line))
index)
(ebox--scroll-build-region-line-bounds-index new-lines))))
(defun ebox--scroll-build-region-line-span-index (lines)
"Return an index mapping region ids to cached line spans in LINES."
(let ((index (make-hash-table :test 'equal)))
(cl-loop for line in lines
for line-index from 0
do
(dolist (region-id (ebox--scroll-line-region-ids line))
(when-let ((span (ebox--scroll-line-region-span
line (ebox--region-id-set
(list region-id)))))
(puthash region-id
(cons (cons line-index span)
(gethash region-id index))
index))))
(maphash (lambda (region-id spans)
(puthash region-id (nreverse spans) index))
index)
index))
(defun ebox--scroll-build-content-region-id-set (lines)
"Return a set of every content-topology region id represented in LINES.
Normal Ebox lines expose each box through `ebox-content',
`ebox-content-owner', or `ebox-content-owners'. Decoration-only legacy lines
fall back to the complete role scanner."
(let ((set (make-hash-table :test 'equal)))
(dolist (line lines)
(dolist (region-id (ebox--scroll-line-content-region-ids line))
(puthash region-id t set)))
set))
(defun ebox--scroll-state-ensure-content-region-id-set (state)
"Return STATE's cached content region-id membership set."
(or (plist-get state :content-region-id-set)
(let ((set (ebox--scroll-build-content-region-id-set
(plist-get state :content-lines))))
(plist-put state :content-region-id-set set)
set)))
(defun ebox--scroll-region-id-set-intersects-p (set region-ids)
"Return non-nil when SET contains at least one of REGION-IDS."
(catch 'present
(dolist (region-id region-ids)
(when (gethash region-id set)
(throw 'present t)))
nil))
(defun ebox--scroll-index-line-spans (index region-ids)
"Return merged line spans for REGION-IDS from INDEX."
(let ((by-line (make-hash-table :test 'eql))
spans)
(dolist (region-id region-ids)
(dolist (entry (gethash region-id index))
(let* ((line-index (car entry))
(span (cdr entry))
(existing (gethash line-index by-line)))
(if existing
(progn
(setcar existing (min (car existing) (car span)))
(setcdr existing (max (cdr existing) (cdr span))))
(puthash line-index (copy-tree entry) by-line)))))
(maphash (lambda (_line-index entry)
(push entry spans))
by-line)
(sort spans (lambda (left right) (< (car left) (car right))))))
(defun ebox--scroll-index-add-line (index line-index line)
"Add LINE at LINE-INDEX to INDEX."
(dolist (region-id (ebox--scroll-line-region-ids line))
(when-let ((span (ebox--scroll-line-region-span
line (ebox--region-id-set (list region-id)))))
(puthash region-id
(cons (cons line-index span)
(gethash region-id index))
index))))
(defun ebox--scroll-sort-line-index (index)
"Sort INDEX entries by line number."
(maphash
(lambda (region-id entries)
(puthash region-id
(sort entries
(lambda (left right)
(< (car left) (car right))))
index))
index)
index)
(defun ebox--scroll-state-set-lines
(state content-lines &optional rendered-lines)
"Return STATE with CONTENT-LINES and derived span indexes installed."
(let ((old-lines (plist-get state :content-lines)))
(setq state
(plist-put
state :region-line-bounds-index
(ebox--scroll-update-region-line-bounds-index
state old-lines content-lines))))
(setq state (plist-put state :content-lines content-lines))
(setq state (ebox--plist-remove state :content-region-id-set))
(setq state (ebox--plist-remove state :region-line-span-hints))
(if ebox--defer-scroll-content-index
(progn
(setq state (plist-put state :region-line-span-index nil))
(setq state (plist-put state :region-line-span-index-deferred t)))
(setq state
(plist-put state :region-line-span-index
(ebox--scroll-build-region-line-span-index
content-lines)))
(setq state (plist-put state :region-line-span-index-deferred nil)))
(if rendered-lines
(progn
(setq state (plist-put state :rendered-content-lines rendered-lines))
(if ebox--defer-scroll-content-index
(progn
(setq state
(plist-put state :rendered-region-line-span-index nil))
(setq state
(plist-put
state :rendered-region-line-span-index-deferred t)))
(setq state
(plist-put state :rendered-region-line-span-index
(ebox--scroll-build-region-line-span-index
rendered-lines)))
(setq state
(plist-put
state :rendered-region-line-span-index-deferred nil))))
(setq state (ebox--plist-remove state :rendered-content-lines))
(setq state (ebox--plist-remove state :rendered-region-line-span-index))
(setq state
(ebox--plist-remove state
:rendered-region-line-span-index-deferred)))
state)
(defun ebox--scroll-state-ensure-region-line-span-index (state)
"Return STATE's content line index, building deferred indexes on demand."
(or (plist-get state :region-line-span-index)
(when (plist-get state :region-line-span-index-deferred)
(let ((index (ebox--scroll-build-region-line-span-index
(plist-get state :content-lines))))
(plist-put state :region-line-span-index index)
(plist-put state :region-line-span-index-deferred nil)
index))))
(defun ebox--scroll-state-ensure-rendered-line-span-index (state)
"Return STATE's rendered line index, building deferred indexes on demand."
(or (plist-get state :rendered-region-line-span-index)
(when (plist-get state :rendered-region-line-span-index-deferred)
(let ((index (ebox--scroll-build-region-line-span-index
(plist-get state :rendered-content-lines))))
(plist-put state :rendered-region-line-span-index index)
(plist-put state :rendered-region-line-span-index-deferred nil)
index))))
(defun ebox--scroll-state-patch-lines
(state content-lines content-spans
&optional rendered-lines _rendered-spans region-ids)
"Return STATE with patched lines and deferred derived span indexes.
CONTENT-SPANS and REGION-IDS retain bounded line hints for follow-up updates.
An existing full index is downgraded to line-number hints: its character spans
are never trusted after the patch, but every indexed region remains locatable."
(let ((line-hints
(when-let ((existing
(or (plist-get state :region-line-span-index)
(plist-get state :region-line-span-hints))))
(copy-hash-table existing)))
(content-region-id-set (plist-get state :content-region-id-set))
(ebox--defer-scroll-content-index t))
(setq state
(ebox--scroll-state-set-lines state content-lines rendered-lines))
(when (and content-spans region-ids)
(unless line-hints
(setq line-hints (make-hash-table :test 'equal)))
(dolist (region-id region-ids)
(puthash region-id (copy-tree content-spans) line-hints)))
(when line-hints
(setq state
(plist-put state :region-line-span-hints line-hints)))
;; Runtime updates preserve region topology, so an equal-layout line patch
;; cannot add or remove ids from the cached prefix.
(when content-region-id-set
(setq state
(plist-put state :content-region-id-set
content-region-id-set))))
state)
(defun ebox--scroll-state-line-spans-for-region-ids (state region-ids)
"Return cached content line spans for REGION-IDS in scroll STATE."
(if-let ((index (ebox--scroll-state-ensure-region-line-span-index state)))
(ebox--scroll-index-line-spans index region-ids)
(ebox--scroll-state-region-line-spans
(plist-get state :content-lines) region-ids)))
(defun ebox--scroll-rendered-line-spans-for-region-ids (state region-ids)
"Return cached rendered-content line spans for REGION-IDS in scroll STATE."
(if-let ((index (ebox--scroll-state-ensure-rendered-line-span-index state)))
(ebox--scroll-index-line-spans index region-ids)
(when-let ((lines (plist-get state :rendered-content-lines)))
(ebox--scroll-state-region-line-spans lines region-ids))))
(defun ebox--scroll-hot-content-line-spans (state region-ids)
"Return content spans for REGION-IDS without building a deferred full index.
Dynamic updates reuse an already prepared index. When no ready index exists,
scan for only REGION-IDS; building the all-region index synchronously is much
more expensive than the targeted fallback on a long lazy prefix."
(if-let ((index (plist-get state :region-line-span-index)))
;; A ready exact index already proves both membership and spans. Building
;; the prefix-wide membership set first adds an O(prefix) tax to visible
;; updates after scrolling.
(ebox--scroll-index-line-spans index region-ids)
(let ((content-region-id-set
(ebox--scroll-state-ensure-content-region-id-set state)))
(when (ebox--scroll-region-id-set-intersects-p
content-region-id-set region-ids)
(let ((hints (plist-get state :region-line-span-hints)))
(if-let ((hint-spans
(and hints
(cl-loop for region-id in region-ids
for spans = (gethash region-id hints)
unless spans return nil
append spans))))
(ebox--scroll-line-spans-at-index-hints
(plist-get state :content-lines)
region-ids
hint-spans)
(ebox--scroll-state-region-line-spans
(plist-get state :content-lines) region-ids)))))))
(defun ebox--scroll-line-spans-at-index-hints
(lines region-ids index-hints)
"Return REGION-IDS spans in LINES limited to INDEX-HINTS line numbers.
INDEX-HINTS use the same `(LINE-INDEX . SPAN)' shape as the scroll indexes.
Content and rendered scroll lines share line numbering, so a content hit can
bound rendered-line inspection without scanning or indexing the whole prefix."
(let ((region-set (ebox--region-id-set region-ids))
(indices (delete-dups (mapcar #'car index-hints)))
spans)
(setq indices (sort indices #'<))
(cl-loop for line in lines
for line-index from 0
while indices
do
(cond
((< line-index (car indices)))
((= line-index (car indices))
(when (ebox--scroll-line-region-present-p line region-ids)
(when-let ((span (ebox--scroll-line-region-span
line region-set)))
(push (cons line-index span) spans)))
(setq indices (cdr indices)))))
(nreverse spans)))
(defun ebox--scroll-hot-rendered-line-spans
(state region-ids content-spans)
"Return rendered spans for REGION-IDS without building a deferred index.
Reuse a ready rendered index; otherwise inspect only lines named by
CONTENT-SPANS, whose line numbers are aligned with rendered scroll lines."
(if-let ((index (plist-get state :rendered-region-line-span-index)))
(ebox--scroll-index-line-spans index region-ids)
(when-let ((lines (plist-get state :rendered-content-lines)))
(if (cl-every
(lambda (entry)
(equal (cdr entry)
(ebox--scroll-line-region-span-for-ids
(nth (car entry) lines) region-ids)))
content-spans)
;; The rendered copy can add outer wrapper ownership while retaining
;; the exact target span. Reuse only after direct-role validation.
content-spans
(ebox--scroll-line-spans-at-index-hints
lines region-ids content-spans)))))
(defun ebox--cancel-buffer-reflow-cache-prewarm (buffer)
"Cancel pending predicted reflow-cache warming for BUFFER."
(when-let ((timer (gethash buffer ebox--reflow-cache-prewarm-timers)))
(when (timerp timer)
(cancel-timer timer)))
(remhash buffer ebox--reflow-cache-prewarm-timers))
(defun ebox--cancel-buffer-runtime-prewarm (buffer)
"Cancel and discard pending runtime prewarming for BUFFER."
(ebox--cancel-buffer-reflow-cache-prewarm buffer)
(when-let ((timer (gethash buffer ebox--runtime-prewarm-timers)))
(when (timerp timer)
(cancel-timer timer)))
(remhash buffer ebox--runtime-prewarm-timers)
(remhash buffer ebox--runtime-prewarm-jobs))
(defun ebox--runtime-prewarm-enabled-p ()
"Return non-nil when runtime prewarming may run in this Emacs."
(and ebox-runtime-idle-prewarm
(or (not noninteractive)
ebox--runtime-prewarm-allow-noninteractive)))
(defun ebox--reflow-cache-prewarm-enabled-p ()
"Return non-nil when predicted reflow-cache warming may run."
(and ebox-runtime-idle-reflow-cache-prewarm
(or (not noninteractive)
ebox--runtime-prewarm-allow-noninteractive)))
(defun ebox--reflow-cache-prewarm-auto-maximum-threshold ()
"Return the memory-aware maximum automatic prewarm allocation threshold."
(let* ((minimum (max 1 ebox-reflow-cache-prewarm-gc-auto-min-threshold))
(maximum
(max minimum ebox-reflow-cache-prewarm-gc-auto-max-threshold))
(physical-memory (ebox--physical-memory-bytes))
(memory-limit
(and physical-memory (floor (/ physical-memory 16.0)))))
(max minimum (min maximum (or memory-limit maximum)))))
(defun ebox--reflow-cache-prewarm-auto-layout-horizon (seconds)
"Return the target collection-free layout count for SECONDS."
(let* ((target (max 1 ebox-reflow-cache-prewarm-gc-auto-target-layouts))
(budget (max 0.001 ebox-reflow-cache-prewarm-gc-auto-frame-budget)))
(cond
((or (not (numberp seconds)) (>= seconds (* budget 0.65))) target)
((>= seconds (* budget 0.30))
(max 1 (ceiling (/ (* target 2.0) 3.0))))
(t (max 1 (ceiling (/ target 3.0)))))))
(defun ebox--reflow-cache-prewarm-auto-threshold-for-allocation
(allocated-bytes seconds)
"Return an automatic threshold for ALLOCATED-BYTES and layout SECONDS."
(let* ((minimum (max 1 ebox-reflow-cache-prewarm-gc-auto-min-threshold))
(maximum (ebox--reflow-cache-prewarm-auto-maximum-threshold))
(horizon (ebox--reflow-cache-prewarm-auto-layout-horizon seconds))
(quantum (* 16 1024 1024))
(raw (* (max 1 allocated-bytes) horizon))
(rounded (* quantum (ceiling (/ raw (float quantum))))))
(max
minimum
(min maximum rounded))))
(defun ebox--memory-use-count-delta (before after)
"Return one wrapping `memory-use-counts' delta from BEFORE to AFTER."
(if (>= after before)
(- after before)
(+ (- most-positive-fixnum before) after 1)))
(defun ebox--memory-use-counts-estimated-bytes (before after)
"Estimate allocation bytes between memory count lists BEFORE and AFTER."
(let ((deltas
(cl-mapcar #'ebox--memory-use-count-delta before after)))
;; Emacs reports object counts rather than bytes. These 64-bit object
;; sizes deliberately include headers for the allocation-pressure signal;
;; the 16 MiB quantization absorbs allocator and platform variation.
(+ (* (or (nth 0 deltas) 0) 16) ; conses
(* (or (nth 1 deltas) 0) 16) ; floats
(* (or (nth 2 deltas) 0) 8) ; vector cells
(* (or (nth 3 deltas) 0) 48) ; symbols
(or (nth 4 deltas) 0) ; string characters
(* (or (nth 5 deltas) 0) 40) ; intervals
(* (or (nth 6 deltas) 0) 32)))) ; string headers
(defun ebox--reflow-cache-prewarm-effective-threshold (state)
"Return the configured allocation threshold for predicted render STATE."
(pcase ebox-reflow-cache-prewarm-gc-cons-threshold
('auto
(or (and state (plist-get state :reflow-prewarm-gc-threshold))
(min (ebox--reflow-cache-prewarm-auto-maximum-threshold)
(max ebox-reflow-cache-prewarm-gc-auto-min-threshold
ebox-reflow-cache-prewarm-gc-auto-initial-threshold))))
((pred numberp) ebox-reflow-cache-prewarm-gc-cons-threshold)
(_ nil)))
(defun ebox--record-reflow-cache-prewarm-cost
(state elapsed-seconds gc-count threshold allocated-bytes)
"Record predicted layout cost in STATE after ELAPSED-SECONDS and GC-COUNT.
THRESHOLD is the allocation budget used for the completed prewarm, and
ALLOCATED-BYTES is its estimated allocation pressure."
(when state
(plist-put state :reflow-prewarm-last-seconds elapsed-seconds)
(plist-put state :reflow-prewarm-last-gc-count gc-count)
(plist-put state :reflow-prewarm-last-gc-threshold threshold)
(plist-put state :reflow-prewarm-last-allocation-bytes allocated-bytes)
;; A GC-contaminated sample measures heap scanning, not layout complexity.
;; Keep the previous learned value until one clean prewarm is available.
(when (= gc-count 0)
(let* ((previous
(plist-get state :reflow-prewarm-layout-seconds))
(smoothed
(if (numberp previous)
(+ (* previous 0.75) (* elapsed-seconds 0.25))
elapsed-seconds)))
(plist-put state :reflow-prewarm-layout-seconds smoothed)))
;; Allocation counters are monotonic across collection, so every completed
;; prewarm can improve the pressure estimate even when its time was noisy.
(when (and (numberp allocated-bytes) (> allocated-bytes 0))
(let* ((previous
(plist-get state :reflow-prewarm-allocation-bytes))
(smoothed
(if (numberp previous)
(floor (+ (* previous 0.75) (* allocated-bytes 0.25)))
allocated-bytes)))
(plist-put state :reflow-prewarm-allocation-bytes smoothed)
(plist-put
state :reflow-prewarm-gc-threshold
(ebox--reflow-cache-prewarm-auto-threshold-for-allocation
smoothed (plist-get state :reflow-prewarm-layout-seconds)))))))
(defun ebox--call-with-reflow-cache-prewarm-gc
(state function &rest arguments)
"Call FUNCTION with ARGUMENTS under idle reflow GC pressure settings.
Automatic collection may run while the predicted render is invisible. The
surrounding interactive burst settings are restored before the cached result
can be published by a visible resize frame. Timer errors remain isolated from
ordinary editing; `quit' still propagates so C-g stays responsive."
(let* ((threshold (ebox--reflow-cache-prewarm-effective-threshold state))
(percentage ebox-reflow-cache-prewarm-gc-cons-percentage)
(gc-cons-threshold
(if (numberp threshold) threshold gc-cons-threshold))
(gc-cons-percentage
(if (numberp percentage) percentage gc-cons-percentage))
;; Predicted rendering enters `ebox--with-render-gc' internally. Use
;; the same pressure values there instead of raising them back to the
;; visible-transaction defaults.
(ebox-render-gc-cons-threshold threshold)
(ebox-render-gc-cons-percentage percentage)
(gcs-before gcs-done)
(counts-before (memory-use-counts))
(started (float-time))
result)
(setq result
(condition-case nil
(apply function arguments)
(error nil)))
(when result
(ebox--record-reflow-cache-prewarm-cost
state (- (float-time) started) (- gcs-done gcs-before) threshold
(ebox--memory-use-counts-estimated-bytes
counts-before (memory-use-counts))))
result))
(defun ebox--make-reflow-prewarm-scratch (state revision)
"Create one isolated live-tree copy for render STATE at REVISION."
(let ((root (plist-get state :root-node)))
(when root
(list :source-root root
:tree (ebox-tree-copy-node-structure root)
:live-revision revision
:predicted-region-id nil
:predicted-width nil
:predicted-revision nil
:render-signature-cache (make-hash-table :test 'eq)
:viewport-dependent-node-ids-cache
(make-hash-table :test 'eq)
:viewport-dependent-subtree-cache
(make-hash-table :test 'eq)
:viewport-height-dependent-subtree-cache
(make-hash-table :test 'eq)))))
(defun ebox--live-reflow-prewarm-scratch (state revision)
"Return STATE's isolated tree at live REVISION, recreating it if needed.
An unconfirmed ordinary-width prediction is never treated as the live tree."
(let* ((root (plist-get state :root-node))
(scratch (plist-get state :reflow-prewarm-scratch)))
(unless (and scratch
(eq (plist-get scratch :source-root) root)
(equal (plist-get scratch :live-revision) revision)
(null (plist-get scratch :predicted-revision)))
(setq scratch (ebox--make-reflow-prewarm-scratch state revision))
(plist-put state :reflow-prewarm-scratch scratch))
scratch))
(defun ebox--discard-reflow-prewarm-scratch (state scratch)
"Discard SCRATCH when it is still STATE's active isolated tree."
(when (eq scratch (plist-get state :reflow-prewarm-scratch))
(plist-put state :reflow-prewarm-scratch nil)))
(defun ebox--render-reflow-prewarm-scratch
(buffer state revision scratch viewport-width viewport-height)
"Render SCRATCH offscreen for BUFFER under the supplied viewport context."
(with-current-buffer buffer
(let ((ebox-viewport-width viewport-width)
(ebox-viewport-height viewport-height)
(ebox--render-runtime-revision revision)
(ebox--render-cache-table (plist-get state :render-cache))
(ebox--render-cache-signature-cache
(plist-get scratch :render-signature-cache))
(ebox--viewport-dependent-node-ids-cache
(plist-get scratch :viewport-dependent-node-ids-cache))
(ebox--viewport-dependent-subtree-cache
(plist-get scratch :viewport-dependent-subtree-cache))
(ebox--viewport-height-dependent-subtree-cache
(plist-get scratch :viewport-height-dependent-subtree-cache))
(ebox--render-cache-scroll-state-restorable-p nil)
(ebox--scroll-window-initial-lookahead-lines-override 0)
(ebox--defer-scroll-content-index t)
(ebox--scroll-global-state (make-hash-table :test 'equal))
(ebox--region-box-table (make-hash-table :test 'equal))
(ebox--scroll-idle-prefetch-timers
(make-hash-table :test 'equal))
(ebox-cache-report-buffer nil)
(ebox--render-region-id nil))
(cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (&rest _) nil)))
(ebox--with-render-gc
(let ((rendered
(ebox--render-with-cache
(plist-get scratch :tree) t)))
(ebox--rendered-root-metadata rendered)))))))
(defun ebox--prewarm-buffer-render-cache-at-context
(buffer state revision source-width viewport-width viewport-height)
"Warm BUFFER's render cache for a predicted viewport context.
STATE and REVISION identify the runtime that scheduled the work.
SOURCE-WIDTH must remain BUFFER's published viewport width. VIEWPORT-WIDTH
and VIEWPORT-HEIGHT are used only in an isolated render; the buffer, runtime
tree, scroll state, region table, report, and body-signature cache are not
published or mutated. Return non-nil when the isolated render ran."
(when (and (buffer-live-p buffer)
(eq state (ebox--buffer-render-state buffer))
(= revision (or (plist-get state :runtime-revision) 0))
(= source-width (or (plist-get state :viewport-width) -1))
(numberp viewport-width)
(> viewport-width 0)
(plist-get state :root-node))
(let ((scratch (ebox--live-reflow-prewarm-scratch state revision))
completed)
(when scratch
(unwind-protect
(progn
(ebox--render-reflow-prewarm-scratch
buffer state revision scratch viewport-width viewport-height)
(setq completed t))
(unless completed
(ebox--discard-reflow-prewarm-scratch state scratch))))
completed)))
(defun ebox--root-region-box (root region-id)
"Return ROOT's box carrying REGION-ID, including a flex wrapper box."
(when-let ((owner (car (ebox--node-path-to-region root region-id))))
(cond
((eq (plist-get owner :ebox-type) 'box) owner)
((and (eq (plist-get owner :ebox-type) 'flex)
(equal (ebox-get (plist-get owner :box) :region-id) region-id))
(plist-get owner :box)))))
(defun ebox--literal-root-pixel-width (box)
"Return BOX's demo-compatible literal pixel width, or nil."
(let ((width (and box (ebox-get box :width))))
(cond
((numberp width) width)
((and (consp width) (null (cdr width)) (numberp (car width)))
(car width)))))
(defun ebox--invalidate-reflow-prewarm-scratch-signatures
(scratch region-id)
"Invalidate REGION-ID's path in SCRATCH's recursive signature cache."
(let* ((root (plist-get scratch :tree))
(cache (plist-get scratch :render-signature-cache))
(box (ebox--root-region-box root region-id)))
(when cache
(dolist (node (ebox--node-path-to-region root region-id))
(remhash node cache))
;; A flex wrapper's editable box is nested in the owner node and can have
;; its own memoized signature.
(when box
(remhash box cache)))))
(defun ebox--confirm-reflow-prewarm-scratch
(buffer region-id revision)
"Confirm BUFFER's predicted root REGION-ID at the new REVISION.
Return non-nil only when both live and isolated trees reached the exact width;
otherwise discard the isolated tree."
(when-let* ((state (ebox--buffer-render-state buffer))
(scratch (plist-get state :reflow-prewarm-scratch)))
(let* ((root (plist-get state :root-node))
(scratch-root (plist-get scratch :tree))
(live-box (ebox--root-region-box root region-id))
(scratch-box (ebox--root-region-box scratch-root region-id))
(live-width (ebox--literal-root-pixel-width live-box))
(scratch-width (ebox--literal-root-pixel-width scratch-box))
(predicted-width (plist-get scratch :predicted-width))
(matched
(and (eq (plist-get scratch :source-root) root)
(equal (plist-get scratch :predicted-region-id) region-id)
(equal (plist-get scratch :predicted-revision) revision)
(numberp predicted-width)
(numberp live-width)
(numberp scratch-width)
(= predicted-width live-width)
(= predicted-width scratch-width))))
(if matched
(progn
(plist-put scratch :live-revision revision)
(plist-put scratch :predicted-region-id nil)
(plist-put scratch :predicted-width nil)
(plist-put scratch :predicted-revision nil)
t)
(ebox--discard-reflow-prewarm-scratch state scratch)
nil))))
(defun ebox--prewarm-buffer-render-cache-at-root-width
(buffer state revision region-id source-width target-width)
"Warm BUFFER cache for REGION-ID's predicted ordinary root TARGET-WIDTH.
STATE and REVISION guard the published runtime. SOURCE-WIDTH must still match
the live root constraint. Rendering mutates only an isolated tree copy and
publishes the prepared rendered-body entry plus its derived root templates."
(when (and (buffer-live-p buffer)
(eq state (ebox--buffer-render-state buffer))
(= revision (or (plist-get state :runtime-revision) 0))
(numberp source-width)
(numberp target-width)
(> target-width 0)
(let ((box (ebox--root-region-box
(plist-get state :root-node) region-id)))
(and box
(= source-width
(or (ebox--literal-root-pixel-width box) -1)))))
(let* ((scratch (ebox--live-reflow-prewarm-scratch state revision))
(box-copy
(and scratch
(ebox--root-region-box
(plist-get scratch :tree) region-id)))
completed)
(if (not box-copy)
(ebox--discard-reflow-prewarm-scratch state scratch)
(unwind-protect
(progn
(ebox--invalidate-reflow-prewarm-scratch-signatures
scratch region-id)
(ebox-put box-copy :width target-width)
(ebox--render-reflow-prewarm-scratch
buffer state revision scratch
(plist-get state :viewport-width)
(plist-get state :viewport-height))
(plist-put scratch :predicted-region-id region-id)
(plist-put scratch :predicted-width target-width)
(plist-put scratch :predicted-revision (1+ revision))
(setq completed t))
(unless completed
(ebox--discard-reflow-prewarm-scratch state scratch))))
completed)))
(defun ebox--run-root-width-cache-prewarm
(buffer state revision region-id source-width target-width)
"Run one scheduled ordinary root width cache warm."
(remhash buffer ebox--reflow-cache-prewarm-timers)
(prog1
(ebox--call-with-reflow-cache-prewarm-gc
state
#'ebox--prewarm-buffer-render-cache-at-root-width
buffer state revision region-id source-width target-width)
(ebox--deferred-render-gc-raise-threshold
(plist-get state :reflow-prewarm-gc-threshold))))
(defun ebox--schedule-buffer-root-width-cache-prewarm
(buffer region-id source-width target-width)
"Schedule isolated cache warming for an ordinary root TARGET-WIDTH."
(when-let* ((state (and (ebox--reflow-cache-prewarm-enabled-p)
(buffer-live-p buffer)
(ebox--buffer-render-state buffer)))
(box (ebox--root-region-box
(plist-get state :root-node) region-id)))
(when (and (numberp source-width) (numberp target-width)
(> target-width 0) (/= source-width target-width)
(= source-width
(or (ebox--literal-root-pixel-width box) -1)))
(ebox--cancel-buffer-reflow-cache-prewarm buffer)
(let ((revision (or (plist-get state :runtime-revision) 0)))
(puthash
buffer
(run-with-idle-timer
(max 0 ebox-runtime-idle-reflow-cache-prewarm-delay)
nil #'ebox--run-root-width-cache-prewarm
buffer state revision region-id source-width target-width)
ebox--reflow-cache-prewarm-timers)))))
(defun ebox--run-reflow-cache-prewarm
(buffer state revision source-width viewport-width viewport-height)
"Run one scheduled predicted reflow-cache warm for BUFFER."
(remhash buffer ebox--reflow-cache-prewarm-timers)
(prog1
(ebox--call-with-reflow-cache-prewarm-gc
state
#'ebox--prewarm-buffer-render-cache-at-context
buffer state revision source-width viewport-width viewport-height)
(ebox--deferred-render-gc-raise-threshold
(plist-get state :reflow-prewarm-gc-threshold))))
(defun ebox--schedule-buffer-reflow-cache-prewarm
(buffer old-viewport-width viewport-width)
"Predict BUFFER's next steady viewport width and warm its render cache.
OLD-VIEWPORT-WIDTH and VIEWPORT-WIDTH describe the completed reflow. One job
is scheduled for the next width at the same nonzero delta; invalid nonpositive
predictions are skipped."
(ebox--cancel-buffer-reflow-cache-prewarm buffer)
(when-let ((state (and (ebox--reflow-cache-prewarm-enabled-p)
(buffer-live-p buffer)
(ebox--buffer-render-state buffer))))
(let* ((delta (and (numberp old-viewport-width)
(numberp viewport-width)
(- viewport-width old-viewport-width)))
(target-width (and delta (+ viewport-width delta))))
(plist-put state :last-viewport-width-delta
(and delta (/= delta 0) delta))
(when (and delta
(/= delta 0)
(numberp target-width)
(> target-width 0))
(let ((revision (or (plist-get state :runtime-revision) 0))
(viewport-height (plist-get state :viewport-height)))
(puthash
buffer
(run-with-idle-timer
(max 0 ebox-runtime-idle-reflow-cache-prewarm-delay)
nil #'ebox--run-reflow-cache-prewarm
buffer state revision viewport-width target-width viewport-height)
ebox--reflow-cache-prewarm-timers))))))
(defun ebox--runtime-prewarm-new-job (state)
"Return a new runtime prewarm job for render STATE."
(list :render-state state
:runtime-revision (or (plist-get state :runtime-revision) 0)
:phase 'role-index
:snapshot-stack
(when-let ((root (plist-get state :root-node)))
(list (vector root :uninitialized t t)))
:node-region-ids-cache (make-hash-table :test 'eq)
:viewport-width-ids-rev nil
:viewport-height-ids-rev nil
:scroll-region-ids (plist-get state :scroll-region-ids)
:scroll-sources nil
:scroll-task nil))
(defun ebox--runtime-prewarm-role-index-slice (buffer job)
"Materialize BUFFER's prepared publication indexes before interactions."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when-let ((table (ebox-buffer--region-role-span-table)))
(unless (ebox-buffer--marker-role-span-table-p table)
(ebox-buffer-materialize-region-role-spans buffer)))
(unless (or (ebox--materialize-box-extent-template buffer)
(when-let* ((root-id (ebox--buffer-root-node-id buffer))
(extents (gethash root-id ebox--box-extents)))
(and (eq (marker-buffer (car extents)) buffer)
(eq (marker-buffer (cdr extents)) buffer))))
(ebox--clear-buffer-extents buffer)
(ebox--register-box-extents-in-range (point-min) (point-max)))))
(plist-put job :phase 'snapshots))
(defun ebox--runtime-prewarm-schedule-timer (buffer &optional delay)
"Schedule BUFFER's next runtime prewarm slice after DELAY."
(when (and (buffer-live-p buffer)
(gethash buffer ebox--runtime-prewarm-jobs))
(when-let ((old (gethash buffer ebox--runtime-prewarm-timers)))
(when (timerp old)
(cancel-timer old)))
(puthash buffer
(run-with-idle-timer
(max 0 (or delay ebox-runtime-idle-prewarm-delay))
nil #'ebox--runtime-prewarm-timer buffer)
ebox--runtime-prewarm-timers)))
(defun ebox--schedule-buffer-runtime-prewarm (buffer &optional delay)
"Schedule shared incremental-update prewarming for BUFFER."
(ebox--cancel-buffer-runtime-prewarm buffer)
(when-let ((state (and (ebox--runtime-prewarm-enabled-p)
(buffer-live-p buffer)
(ebox--buffer-render-state buffer))))
(puthash buffer (ebox--runtime-prewarm-new-job state)
ebox--runtime-prewarm-jobs)
(ebox--runtime-prewarm-schedule-timer buffer delay)))
(defun ebox--runtime-prewarm-record-viewport-node
(job node width-allowed)
"Record NODE's direct viewport dependencies in JOB.
Return non-nil when descendant width dependencies remain relevant."
(let ((width-direct (ebox--node-direct-viewport-width-dependent-p node))
(vertical (ebox--node-direct-viewport-height-dependent-p node)))
(when (and width-direct width-allowed)
(plist-put job :viewport-width-ids-rev
(cons (ebox--ensure-node-id node)
(plist-get job :viewport-width-ids-rev))))
(when vertical
(plist-put job :viewport-height-ids-rev
(cons (ebox--ensure-node-id node)
(plist-get job :viewport-height-ids-rev))))
(and width-allowed
(not (ebox--render-cache-contained-viewport-node-p node)))))
(defun ebox--runtime-prewarm-snapshot-slice (buffer job)
"Advance BUFFER's post-order lightweight snapshot frontier for JOB."
(let ((limit (max 1 ebox-runtime-idle-prewarm-slice-size))
(count 0)
(ebox--node-region-ids-cache
(plist-get job :node-region-ids-cache)))
(while (and (< count limit) (plist-get job :snapshot-stack))
(let* ((stack (plist-get job :snapshot-stack))
(frame (car stack))
(node (aref frame 0))
(children (aref frame 1))
(width-allowed (aref frame 2)))
(cond
((eq children :uninitialized)
(aset frame 1 (ebox-tree--children-raw node))
(aset frame 3
(ebox--runtime-prewarm-record-viewport-node
job node width-allowed)))
(children
(aset frame 1 (cdr children))
(plist-put job :snapshot-stack
(cons (vector (car children) :uninitialized
(aref frame 3) (aref frame 3))
stack)))
(t
(plist-put job :snapshot-stack (cdr stack))
(when (and (listp node) (not (stringp node)))
(let ((node-id (ebox--ensure-node-id node)))
(ebox--put-layout-snapshot
buffer node-id
(ebox--node-layout-snapshot buffer node nil))))))
(setq count (1+ count))))
(unless (plist-get job :snapshot-stack)
(when-let ((state (ebox--buffer-render-state buffer)))
(let ((width-ids (nreverse
(plist-get job :viewport-width-ids-rev)))
(height-ids (nreverse
(plist-get job :viewport-height-ids-rev))))
(plist-put state :layout-snapshots-complete-p t)
(plist-put state :viewport-dependent-node-id-axes
(cons width-ids height-ids))
(plist-put state :viewport-dependent-node-ids
(delete-dups
(copy-sequence (append width-ids height-ids)))))
(plist-put state :viewport-dependent-node-ids-ready t))
(plist-put job :phase 'scroll))))
(defun ebox--runtime-prewarm-add-line (task line)
"Add LINE to TASK's index without a later whole-index sort."
(let ((index (plist-get task :index))
(tails (plist-get task :tails))
(line-index (plist-get task :line-index)))
(dolist (region-id (ebox--scroll-line-region-ids line))
(when-let ((span (ebox--scroll-line-region-span
line (ebox--region-id-set (list region-id)))))
(let* ((cell (list (cons line-index span)))
(tail (gethash region-id tails)))
(if tail
(setcdr tail cell)
(puthash region-id cell index))
(puthash region-id cell tails))))
(plist-put task :line-index (1+ line-index))))
(defun ebox--runtime-prewarm-scroll-source-task (buffer source)
"Return a bounded line-index task for SOURCE owned by BUFFER."
(let* ((region-id (plist-get source :region-id))
(state (plist-get source :state))
(lines-key (plist-get source :lines-key))
(lines (plist-get state lines-key)))
(when (and (eq (ebox--scroll-get-state region-id) state)
(eq (ebox--scroll-state-buffer state) buffer)
(plist-get state (plist-get source :deferred-key))
lines)
(list :region-id region-id
:state state
:index-key (plist-get source :index-key)
:deferred-key (plist-get source :deferred-key)
:lines-key lines-key
:lines lines
:remaining-lines lines
:line-index 0
:index (make-hash-table :test 'equal)
:tails (make-hash-table :test 'equal)))))
(defun ebox--runtime-prewarm-scroll-sources (region-id state)
"Return the two cached-line index sources for REGION-ID and STATE."
(list
(list :region-id region-id
:state state
:index-key :region-line-span-index
:deferred-key :region-line-span-index-deferred
:lines-key :content-lines)
(list :region-id region-id
:state state
:index-key :rendered-region-line-span-index
:deferred-key :rendered-region-line-span-index-deferred
:lines-key :rendered-content-lines)))
(defun ebox--runtime-prewarm-scroll-slice (buffer job)
"Prepare one cached scroll-line index slice for BUFFER's JOB."
(let ((limit (max 1 ebox-runtime-idle-prewarm-slice-size))
(count 0))
(while (and (< count limit)
(not (eq (plist-get job :phase) 'done)))
(cond
((plist-get job :scroll-task)
(let* ((task (plist-get job :scroll-task))
(state (plist-get task :state))
(lines (plist-get task :lines))
(remaining (plist-get task :remaining-lines)))
(cond
((or (not (eq (ebox--scroll-get-state
(plist-get task :region-id))
state))
(not (eq lines
(plist-get state (plist-get task :lines-key)))))
(plist-put job :scroll-task nil))
(remaining
(ebox--runtime-prewarm-add-line task (car remaining))
(plist-put task :remaining-lines (cdr remaining))
(unless (cdr remaining)
(plist-put state (plist-get task :index-key)
(plist-get task :index))
(plist-put state (plist-get task :deferred-key) nil)
(plist-put job :scroll-task nil)))
(t
(plist-put job :scroll-task nil))))
(setq count (1+ count)))
((plist-get job :scroll-sources)
(let* ((sources (plist-get job :scroll-sources))
(source (car sources)))
(plist-put job :scroll-sources (cdr sources))
(when-let ((task
(ebox--runtime-prewarm-scroll-source-task
buffer source)))
(plist-put job :scroll-task task)))
(setq count (1+ count)))
((plist-get job :scroll-region-ids)
(let* ((region-ids (plist-get job :scroll-region-ids))
(region-id (car region-ids))
(state (ebox--scroll-get-state region-id)))
(plist-put job :scroll-region-ids (cdr region-ids))
(when (and state (eq (ebox--scroll-state-buffer state) buffer))
(plist-put job :scroll-sources
(ebox--runtime-prewarm-scroll-sources
region-id state))))
(setq count (1+ count)))
(t
(plist-put job :phase 'done))))))
(defun ebox--runtime-prewarm-step (buffer)
"Run one bounded runtime prewarm slice for BUFFER.
Return non-nil while more work remains."
(when-let ((job (gethash buffer ebox--runtime-prewarm-jobs)))
(let ((state (ebox--buffer-render-state buffer)))
(if (or (not (buffer-live-p buffer))
(not (eq state (plist-get job :render-state)))
(/= (or (plist-get state :runtime-revision) 0)
(plist-get job :runtime-revision)))
(ebox--cancel-buffer-runtime-prewarm buffer)
(pcase (plist-get job :phase)
('role-index
(ebox--runtime-prewarm-role-index-slice buffer job))
('snapshots
(ebox--runtime-prewarm-snapshot-slice buffer job))
('scroll
(ebox--runtime-prewarm-scroll-slice buffer job)))
(if (eq (plist-get job :phase) 'done)
(progn
(remhash buffer ebox--runtime-prewarm-jobs)
nil)
t)))))
(defun ebox--runtime-prewarm-timer (buffer)
"Run BUFFER's scheduled runtime prewarm slice."
(remhash buffer ebox--runtime-prewarm-timers)
(when (ebox--runtime-prewarm-step buffer)
;; A newly registered idle timer is measured from the beginning of the
;; current idle period. Scheduling it with zero (or only the base delay)
;; makes every remaining slice immediately ripe once the first slice runs,
;; monopolizing redisplay until a large runtime is fully prewarmed.
(ebox--runtime-prewarm-schedule-timer
buffer
(ebox--idle-continuation-delay ebox-runtime-idle-prewarm-delay))))
(defun ebox--runtime-prewarm-drain (buffer)
"Synchronously drain BUFFER's scheduled prewarm job for tests and profiling."
(when-let ((timer (gethash buffer ebox--runtime-prewarm-timers)))
(when (timerp timer)
(cancel-timer timer))
(remhash buffer ebox--runtime-prewarm-timers))
(let ((limit 100000)
completed)
(while (and (> limit 0) (not completed))
(unless (gethash buffer ebox--runtime-prewarm-jobs)
(when-let ((state (and (buffer-live-p buffer)
(ebox--buffer-render-state buffer))))
(puthash buffer (ebox--runtime-prewarm-new-job state)
ebox--runtime-prewarm-jobs)))
(if (ebox--runtime-prewarm-step buffer)
(setq limit (1- limit))
(setq completed t)))
(when (not completed)
(error "Runtime prewarm did not converge for %S" buffer))))
(defun ebox--refresh-layout-snapshots-for-node (buffer node)
"Refresh lightweight snapshots for NODE's runtime subtree in BUFFER."
(ebox--invalidate-buffer-layout-snapshot-details buffer)
(let ((ebox--node-region-ids-cache (make-hash-table :test 'eq)))
(ebox--capture-layout-snapshots buffer node)))
(defun ebox--scroll-line-with-preserved-wrapper-owner
(line span replacement rendered-owner-set)
"Return REPLACEMENT preserving wrapper context from LINE SPAN.
RENDERED-OWNER-SET identifies owners supplied by the complete fresh render."
(let ((owners (or (get-text-property (car span) 'ebox-content-owners line)
(when-let ((owner (get-text-property
(car span)
'ebox-content-owner
line)))
(list owner)))))
(if owners
(ebox--string-with-preserved-wrapper-context
replacement owners rendered-owner-set)
replacement)))
(defun ebox--scroll-pad-line-to-slot-width (line width)
"Return LINE padded to visual slot WIDTH, or nil when LINE is too wide."
(let* ((line-width (ebox--string-pixel-width line))
(extra (- width line-width)))
(when (>= extra 0)
(concat line (ebox-pixel-space extra)))))
(defun ebox--scroll-patch-lines-for-region
(lines region-ids rendered &optional spans rendered-owner-set)
"Return LINES with REGION-IDS replaced by RENDERED, or nil on mismatch.
RENDERED-OWNER-SET reuses an existing content-owner scan when supplied."
(let* ((spans (or spans
(ebox--scroll-state-region-line-spans lines region-ids)))
(rendered-lines (ebox-string-lines rendered))
(rendered-owner-set
(or rendered-owner-set
(ebox--string-content-owner-set rendered))))
(when (and spans
(<= (length rendered-lines) (length spans)))
(catch 'failed
(let ((result (copy-sequence lines)))
(cl-loop for entry in spans
for replacement = (or (pop rendered-lines)
(ebox-pixel-space 0))
do
(let* ((idx (car entry))
(span (cdr entry))
(line (nth idx result))
(old-slot (substring line (car span) (cdr span)))
(slot-width (ebox--string-pixel-width old-slot))
(padded (ebox--scroll-pad-line-to-slot-width
replacement slot-width)))
(unless padded
(throw 'failed nil))
(setq padded
(ebox--scroll-line-with-preserved-wrapper-owner
line span padded rendered-owner-set))
(setf (nth idx result)
(concat (substring line 0 (car span))
padded
(substring line (cdr span))))))
result)))))
(defun ebox--scroll-patch-rendered-lines-for-region
(state region-ids rendered)
"Return rendered content lines patched for REGION-IDS, or nil."
(when-let* ((rendered-lines (plist-get state :rendered-content-lines))
(rendered-spans
(ebox--scroll-rendered-line-spans-for-region-ids
state region-ids)))
(ebox--scroll-patch-lines-for-region
rendered-lines region-ids rendered rendered-spans)))
(defun ebox--scroll-state-visible-range (state)
"Return visible content index range for scroll STATE as (START . END)."
(let* ((content-lines (plist-get state :content-lines))
(content-height (or (plist-get state :content-height) 0))
(max-offset (max 0 (- (length content-lines) content-height)))
(scroll-offset (max 0 (min max-offset
(or (plist-get state :scroll-offset)
0)))))
(cons scroll-offset
(min (+ scroll-offset content-height)
(length content-lines)))))
(defun ebox--scroll-state-spans-visible-p (state spans)
"Return non-nil when any SPANS entry falls inside STATE's visible range."
(pcase-let ((`(,start . ,end) (ebox--scroll-state-visible-range state)))
(cl-some (lambda (entry)
(let ((idx (car entry)))
(and (>= idx start) (< idx end))))
spans)))
(defun ebox--scroll-apply-visible-node-span-patch
(buffer node changed-keys region-ids rendered rendered-owner-set)
"Patch visible NODE directly while its cached scroll lines are synchronized.
Return backend patch metadata on success. The ordinary span backend preserves
the current parent slot and wrapper paint, avoiding a fixed-height scroll
viewport replacement when only NODE's proven-stable visible spans changed.
RENDERED and RENDERED-OWNER-SET reuse the scroll cache patch's fresh render."
(let* ((node-id (plist-get node :node-id))
(dirty (list :node-id node-id
:dirty-kind 'geometry
:changed-keys changed-keys
:region-ids region-ids))
(op (list :op 'span-patch :owner-id node-id :dirty dirty))
(result (ebox-buffer-apply-span-patch
buffer op rendered rendered-owner-set)))
(when result
(ebox--refresh-runtime-indexes-for-patch-results
buffer (list result) 'span-patch)
(ebox--refresh-text-patch-layout-snapshots buffer (list result))
result)))
(defun ebox--try-scroll-state-span-patch
(buffer region-id node changed-keys
&optional skip-materialize allow-nonlocal-keys)
"Patch cached scroll state lines for REGION-ID/NODE in BUFFER.
Return an update report when all containing scroll states were patched.
When ALLOW-NONLOCAL-KEYS is non-nil, the rendered replacement still must fit
the cached line slots, but CHANGED-KEYS need not belong to the visible-buffer
span-patch whitelist. This is used only for semantically hidden nodes."
(when (and buffer node
changed-keys
(or allow-nonlocal-keys
(ebox--scroll-state-span-patch-keys-p changed-keys)))
(let* ((region-ids (ebox--node-all-region-ids node))
(containing-scroll-state-count
(ebox--node-containing-scroll-state-count buffer node))
rendered
rendered-owner-set
patched visible-patched span-count
rendered-range-patched
index-used
visible-buffer-patched
visible-direct-patched
patches
(ok
(catch 'scroll-state-patch-failed
(maphash
(lambda (scroll-region-id state)
(when (and
(ebox--scroll-state-covers-node-p buffer state node)
;; A scroll container's own offset is state, not one
;; of its cached descendant lines. Treating the whole
;; container as a line replacement can publish the new
;; viewport while leaving :scroll-offset stale.
(not (and (equal changed-keys '(:scroll-offset))
(equal scroll-region-id region-id))))
(let* ((content-lines (plist-get state :content-lines))
(content-spans
(and content-lines
(progn
(when (plist-get
state :region-line-span-index)
(setq index-used t))
(ebox--scroll-hot-content-line-spans
state region-ids)))))
(when (and (not skip-materialize)
(not content-spans)
(not (ebox--node-visible-in-buffer-p
buffer node))
(plist-get state :materialize-content-lines))
(setq state
(ebox--scroll-state-materialize-lines
scroll-region-id state))
(setq content-lines (plist-get state :content-lines))
(setq content-spans
(and content-lines
(progn
(when (plist-get
state :region-line-span-index)
(setq index-used t))
(ebox--scroll-hot-content-line-spans
state region-ids)))))
(when content-spans
(unless rendered
(setq rendered
(ebox--with-buffer-render-context buffer
(or
(when-let ((snapshot
(and
(ebox--node-visible-in-buffer-p
buffer node)
(ebox--direct-flex-parent-p
buffer
(plist-get node :node-id))
(ebox--ensure-layout-snapshot-spans
buffer
(plist-get node :node-id)))))
(ebox--render-node-in-current-flex-slot
buffer
(plist-get node :node-id)
snapshot changed-keys))
(ebox-render node))))
(setq rendered-owner-set
(ebox--string-content-owner-set rendered)))
(let* ((new-content-lines
(ebox--scroll-patch-lines-for-region
content-lines region-ids rendered
content-spans rendered-owner-set))
(rendered-lines
(plist-get state :rendered-content-lines))
(rendered-spans
(and rendered-lines
(progn
(when (plist-get
state
:rendered-region-line-span-index)
(setq index-used t))
(ebox--scroll-hot-rendered-line-spans
state region-ids content-spans))))
(new-rendered-lines
(if rendered-lines
(ebox--scroll-patch-lines-for-region
rendered-lines region-ids rendered
rendered-spans rendered-owner-set)
nil)))
(unless (and new-content-lines
(or (not rendered-lines)
new-rendered-lines))
(throw 'scroll-state-patch-failed nil))
(setq patched t)
(when (and rendered-lines new-rendered-lines)
(setq rendered-range-patched t))
(setq span-count (+ (or span-count 0)
(length content-spans)))
(push (list :scroll-region-id scroll-region-id
:state state
:content-lines new-content-lines
:rendered-lines new-rendered-lines
:has-rendered-lines rendered-lines
:content-spans content-spans
:rendered-spans rendered-spans)
patches))))))
ebox--scroll-global-state)
t)))
(when (and ok patched)
(catch 'scroll-state-apply-failed
(dolist (patch (nreverse patches))
(let ((state (plist-get patch :state))
(scroll-region-id (plist-get patch :scroll-region-id))
(new-content-lines (plist-get patch :content-lines)))
(setq state
(ebox--scroll-state-patch-lines
state new-content-lines
(plist-get patch :content-spans)
(and (plist-get patch :has-rendered-lines)
(plist-get patch :rendered-lines))
(plist-get patch :rendered-spans)
region-ids))
(puthash scroll-region-id state ebox--scroll-global-state)
(when (ebox--scroll-state-spans-visible-p
state (plist-get patch :content-spans))
(setq visible-patched t)
(unless visible-buffer-patched
(setq visible-direct-patched
(ebox--scroll-apply-visible-node-span-patch
buffer node changed-keys region-ids
rendered rendered-owner-set))
(setq visible-buffer-patched
(or visible-direct-patched
(and
(= containing-scroll-state-count 1)
(let* ((range
(ebox--scroll-state-visible-range state))
(visible-lines
(seq-subseq new-content-lines
(car range) (cdr range))))
(with-current-buffer buffer
(ebox--scroll-update-content
scroll-region-id visible-lines))))))
(unless visible-buffer-patched
(throw 'scroll-state-apply-failed nil))))))
(when visible-patched
(unless visible-direct-patched
(ebox--refresh-layout-snapshots-for-node buffer node)))
(ebox--update-report
region-id 'span-patch
:dirty-count 1
:patch-count 1
:patch-ops '(span-patch)
:owner-id (plist-get node :node-id)
:owner-ids (list (plist-get node :node-id))
:owner-type (plist-get node :ebox-type)
:span-count span-count
:scroll-state-patch t
:scroll-state-index index-used
:scroll-rendered-range-patch rendered-range-patched
:dirty-kinds '(geometry)
:dirty-keys changed-keys))))))
(defun ebox--try-hidden-scroll-state-span-patch
(buffer region-id node changed-keys)
"Patch cached scroll lines for hidden NODE without touching the live buffer.
Complete scroll states still contain offscreen formatted lines, so safe
slot-compatible geometry changes should update those lines directly instead
of falling through to a live owner rerender at stale viewport coordinates."
(when (and buffer node changed-keys)
(when (and (not (ebox--node-visible-in-buffer-p buffer node))
(= (ebox--node-containing-scroll-state-count buffer node)
1))
(ebox--try-scroll-state-span-patch
buffer region-id node changed-keys t t))))
(defun ebox--node-containing-scroll-state-count (buffer node)
"Return the number of BUFFER scroll states that contain NODE."
(let ((count 0))
(maphash
(lambda (_scroll-region-id state)
(when (ebox--scroll-state-covers-node-p buffer state node)
(setq count (1+ count))))
ebox--scroll-global-state)
count))
(defun ebox--try-hidden-scroll-root-fallback
(buffer region-id node changed-keys)
"Rerender BUFFER root when a hidden scroll cache patch cannot fit.
This correctness fallback preserves the current scroll offset and never treats
offscreen owner coordinates as live patch spans."
(let ((region-ids (and node (ebox--node-all-region-ids node))))
(when (and buffer node region-ids
(not (ebox--node-visible-in-buffer-p buffer node))
(> (ebox--node-containing-scroll-state-count buffer node) 0))
(ebox-buffer-apply-root-rerender buffer)
(ebox--update-report
region-id 'root-rerender
:dirty-count 1
:patch-count 0
:patch-ops nil
:owner-id (ebox--buffer-root-node-id buffer)
:owner-ids (list (ebox--buffer-root-node-id buffer))
:root-rerender t
:scroll-state-patch t
:dirty-kinds '(geometry)
:dirty-keys changed-keys))))
(defun ebox--sync-scroll-state-lines-for-node-fallback
(buffer region-id node changed-keys report)
"Synchronize cached scroll lines for updated NODE in BUFFER by rerendering.
Ordinary incremental patching mutates the visible buffer. Scroll containers
also keep their own cached content lines, so those lines must be updated or a
later scroll-window replacement can replay stale content."
(when (and buffer node changed-keys)
(let* ((region-ids (ebox--node-all-region-ids node))
targets
rendered
patched-count
refreshed-count
deferred-count
failed-count
span-count
marker-refresh-count
(visible-node-p
(ebox--node-visible-in-buffer-p buffer node)))
(maphash
(lambda (scroll-region-id state)
(when (and
(ebox--scroll-state-covers-node-p buffer state node)
;; The dedicated scroll-offset transaction has already
;; committed this container's own state/window. Only outer
;; containing scroll caches still need the rendered node.
(not (and (equal changed-keys '(:scroll-offset))
(equal scroll-region-id region-id))))
(let* ((content-lines (plist-get state :content-lines))
(content-spans
(and content-lines
(ebox--scroll-hot-content-line-spans
state region-ids))))
(when (or content-spans visible-node-p)
(push (list :scroll-region-id scroll-region-id
:state state
:content-lines content-lines
:content-spans content-spans
:rendered-lines
(plist-get state :rendered-content-lines))
targets)))))
ebox--scroll-global-state)
(when targets
(setq rendered
(ebox--with-buffer-render-context buffer
(or
(when-let ((snapshot
(and
visible-node-p
(ebox--direct-flex-parent-p
buffer (plist-get node :node-id))
(ebox--ensure-layout-snapshot-spans
buffer (plist-get node :node-id)))))
(ebox--render-node-in-current-flex-slot
buffer (plist-get node :node-id) snapshot changed-keys))
(ebox-render node))))
(dolist (target targets)
(let* ((scroll-region-id (plist-get target :scroll-region-id))
(state (plist-get target :state))
(content-lines (plist-get target :content-lines))
(content-spans (plist-get target :content-spans))
(new-content-lines
(ebox--scroll-patch-lines-for-region
content-lines region-ids rendered content-spans))
(rendered-lines (plist-get target :rendered-lines))
(rendered-spans
(and new-content-lines rendered-lines
(ebox--scroll-hot-rendered-line-spans
state region-ids content-spans)))
(new-rendered-lines
(if (and new-content-lines rendered-lines)
(and rendered-spans
(ebox--scroll-patch-lines-for-region
rendered-lines region-ids rendered
rendered-spans))
nil)))
(when (and new-content-lines
(or (not rendered-lines)
new-rendered-lines))
(setq state
(ebox--scroll-state-patch-lines
state new-content-lines content-spans
(and rendered-lines new-rendered-lines)
rendered-spans region-ids))
(setq state (plist-put state :buffer buffer))
(puthash scroll-region-id state ebox--scroll-global-state)
(plist-put target :cache-synchronized t)
(setq patched-count (1+ (or patched-count 0)))
(setq span-count (+ (or span-count 0)
(length content-spans))))
(unless (and new-content-lines
(or (not rendered-lines)
new-rendered-lines))
;; A geometry change can outgrow its cached line slot. Lazy
;; prefixes can be rebuilt from the current runtime tree. A
;; visible update must publish that cache generation before it
;; returns; otherwise redisplay can expose a buffer/state split
;; until an unrelated annotation or scroll repairs the window.
(if (plist-get state :render-content-prefix)
(progn
(setq state
(plist-put state :lazy-scroll-prefix-dirty t))
(puthash scroll-region-id state
ebox--scroll-global-state)
(let* ((required-lines
(+ (or (plist-get state :scroll-offset) 0)
(or (plist-get state :content-height) 0)))
(rebuilt
(ebox--scroll-state-ensure-prefix-lines
scroll-region-id state required-lines t)))
(if (plist-get rebuilt :lazy-scroll-prefix-dirty)
(progn
(plist-put target :deferred t)
(setq deferred-count
(1+ (or deferred-count 0))))
(plist-put target :cache-synchronized t))))
;; A complete window has no prefix producer left to
;; rebuild from. The publication itself was verified
;; by footprint proofs and only touched visible rows,
;; so adopting the published window back into the
;; cache is exact; marker verification below still
;; guards the result.
(let ((adopted
(ebox--scroll-state-adopt-buffer-window-lines
buffer scroll-region-id state)))
(if adopted
(progn
(setq adopted
(plist-put adopted :buffer buffer))
(puthash scroll-region-id adopted
ebox--scroll-global-state)
(plist-put target :cache-synchronized t)
(setq patched-count
(1+ (or patched-count 0))))
(plist-put target :failed t)
(setq failed-count
(1+ (or failed-count 0))))))))))
;; Commit one marker generation after every cache mutation. Marker-only
;; publication never rewrites visible text, so nested scroll windows
;; cannot invalidate each other as they did with per-state zero-delta
;; scrolling.
(when targets
(ebox--refresh-buffer-scroll-content-markers buffer)
(dolist (target targets)
(when (plist-get target :cache-synchronized)
(let* ((scroll-region-id (plist-get target :scroll-region-id))
(state (ebox--scroll-get-state scroll-region-id)))
(if (and state
(ebox--scroll-state-window-markers-healthy-p
buffer scroll-region-id state))
(progn
(when (plist-get
state :lazy-scroll-window-refresh-required)
(setq refreshed-count
(1+ (or refreshed-count 0))))
(setq state
(ebox--plist-remove
state :lazy-scroll-window-refresh-required))
(puthash scroll-region-id state
ebox--scroll-global-state)
(setq marker-refresh-count
(1+ (or marker-refresh-count 0))))
(unless (plist-get target :failed)
;; In-slot patching cannot represent structural line
;; changes (for example :overflow transitions adding
;; or removing unowned visible-overflow lines), and
;; the divergence only surfaces at this verification.
;; The published window is footprint-verified truth,
;; so first adopt it back into the visible cache
;; slice; only when adoption cannot apply rebuild the
;; prefix from the current runtime exactly like the
;; outgrown-slot branch. Publishing a stale cache
;; that carries no repair marker is never an option.
(cl-flet ((verify ()
(ebox--refresh-buffer-scroll-content-markers
buffer)
(when-let ((current
(ebox--scroll-get-state
scroll-region-id)))
(ebox--scroll-state-window-markers-healthy-p
buffer scroll-region-id current))))
(let* ((adopted
(when-let ((current
(ebox--scroll-get-state
scroll-region-id)))
(ebox--scroll-state-adopt-buffer-window-lines
buffer scroll-region-id current)))
(healthy
(when adopted
(puthash scroll-region-id
(plist-put adopted :buffer buffer)
ebox--scroll-global-state)
(verify)))
(rebuilt-healthy
(unless healthy
(when (plist-get
state :render-content-prefix)
(setq state
(plist-put
state :lazy-scroll-prefix-dirty
t))
(puthash scroll-region-id state
ebox--scroll-global-state)
(let* ((required-lines
(+ (or (plist-get
state :scroll-offset)
0)
(or (plist-get
state :content-height)
0)))
(rebuilt
(ebox--scroll-state-ensure-prefix-lines
scroll-region-id state
required-lines t)))
(and rebuilt
(not (plist-get
rebuilt
:lazy-scroll-prefix-dirty))
(verify))))))
(healthy (or healthy rebuilt-healthy)))
(if healthy
(progn
(when-let ((current (ebox--scroll-get-state
scroll-region-id)))
(puthash scroll-region-id
(ebox--plist-remove
current
:lazy-scroll-window-refresh-required)
ebox--scroll-global-state))
(setq refreshed-count
(1+ (or refreshed-count 0))
marker-refresh-count
(1+ (or marker-refresh-count 0))))
(plist-put target :failed t)
(setq failed-count
(1+ (or failed-count 0))))))))))))
(if (or patched-count refreshed-count deferred-count failed-count)
(append report
(list :scroll-state-sync
(and (not (null (or patched-count
refreshed-count)))
(= (or deferred-count 0) 0)
(= (or failed-count 0) 0))
:scroll-state-sync-count patched-count
:scroll-state-sync-span-count
(or span-count 0)
:scroll-state-sync-refreshed
(not (null refreshed-count))
:scroll-state-sync-refreshed-count
(or refreshed-count 0)
:scroll-state-sync-deferred
(not (null deferred-count))
:scroll-state-sync-deferred-count
(or deferred-count 0)
:scroll-state-sync-failed-count
(or failed-count 0)
:scroll-state-window-marker-refresh-count
(or marker-refresh-count 0)
:scroll-state-window-markers-invalidated
(and (not (null patched-count))
(null marker-refresh-count))))
report))))
(defun ebox--scroll-line-role-spans (line role region-set)
"Return spans in LINE where ROLE belongs to REGION-SET."
(let* ((property (ebox-buffer--role-property role))
(limit (length line))
(pos 0)
spans)
(while (< pos limit)
(let* ((next (or (next-single-property-change pos property line limit)
limit))
(region-id (get-text-property pos property line)))
(when (and region-id (gethash region-id region-set))
(push (cons pos next) spans))
(setq pos next)))
(nreverse spans)))
(defun ebox--scroll-patch-face-key-in-line-span (line start end key value)
"Patch LINE face KEY to VALUE in START..END."
(let ((pos start))
(while (< pos end)
(let* ((next (or (next-single-property-change pos 'face line end)
end))
(face (get-text-property pos 'face line)))
(put-text-property pos next 'face
(ebox-buffer--face-put face key value)
line)
(setq pos next)))))
(defun ebox--scroll-patch-horizontal-border-face-in-line-span
(line start end key value)
"Patch horizontal border face KEY to VALUE in LINE START..END."
(if (eq value ebox-buffer--remove-face-key)
(ebox--scroll-patch-face-key-in-line-span line start end key value)
(add-face-text-property start end (list key value) nil line)))
(defun ebox--scroll-paint-lines-for-region
(lines region-ids surfaces &optional candidate-spans)
"Return LINES with paint SURFACES patched for REGION-IDS.
CANDIDATE-SPANS limits scanning to known lines owned by REGION-IDS. Return a
plist with :lines and possibly empty :spans, or nil when a required inferred
surface is absent."
(let* ((region-set (ebox--region-id-set region-ids))
(result (copy-sequence lines))
(line-indices (if candidate-spans
(delete-dups
(mapcar #'car (copy-sequence candidate-spans)))
(number-sequence 0 (1- (length lines)))))
changed-spans
failed)
(catch 'failed
(dolist (spec surfaces)
(let ((role (plist-get spec :role))
(face-key (plist-get spec :face-key))
(face-value (plist-get spec :face-value))
(missing (plist-get spec :missing))
(remove-role (plist-get spec :remove-role))
(surface-patched nil))
(dolist (idx line-indices)
(when-let ((line (nth idx result)))
(let ((line-copy nil))
(dolist (span (ebox--scroll-line-role-spans
line role region-set))
(unless line-copy
(setq line-copy (copy-sequence line))
(setf (nth idx result) line-copy))
(if (memq role '(bt bb))
(ebox--scroll-patch-horizontal-border-face-in-line-span
line-copy (car span) (cdr span) face-key face-value)
(ebox--scroll-patch-face-key-in-line-span
line-copy (car span) (cdr span) face-key face-value))
(when remove-role
(remove-text-properties
(car span) (cdr span)
(list (ebox-buffer--role-property role) nil)
line-copy))
(push (cons idx span) changed-spans)
(setq surface-patched t)))))
(when (and (not surface-patched)
(eq missing 'infer))
(setq failed t)
(throw 'failed nil))))
(unless failed
(list :lines result :spans (nreverse changed-spans))))))
(defun ebox--scroll-state-adopt-buffer-window-lines (buffer region-id state)
"Adopt BUFFER's live window text into STATE's visible rendered cache.
Buffer and cache paint patches run through different patchers, so
visually identical lines can diverge in raw representation. The
published buffer is the display truth: copying its window lines back
over the visible slice of `:rendered-content-lines' restores exact
representational identity, which keeps later window verification on
the cheap raw-equality path and keeps the settled cache replaying
exactly what is on screen. Only chrome-free windows adopt; decorated
windows keep their semantically patched lines. Return the updated
state, or nil when no adoption happened."
(when-let* ((box (plist-get state :box))
((ebox--scroll-chrome-free-rendered-window-p box))
(rendered-lines (plist-get state :rendered-content-lines))
(height (plist-get state :content-height))
((integerp height))
((> height 0))
(extents (ebox--scroll-state-window-extents region-id state))
(start (car extents))
(end (cdr extents))
((markerp start))
((markerp end))
((eq (marker-buffer start) buffer))
((eq (marker-buffer end) buffer))
(start-pos (marker-position start))
(end-pos (marker-position end))
((< start-pos end-pos)))
(with-current-buffer buffer
(when (= (count-lines start-pos end-pos) height)
(let* ((window-lines
(ebox-string-lines
(buffer-substring start-pos end-pos)))
(offset (or (plist-get state :scroll-offset) 0)))
(when (and (= (length window-lines) height)
(<= (+ offset height) (length rendered-lines)))
(let ((copy (copy-sequence rendered-lines)))
(cl-loop for line in window-lines
for index from offset
do (setf (nth index copy) line))
(setq state
(plist-put state :rendered-content-lines copy)))
(when-let* ((content-lines (plist-get state :content-lines))
((<= (+ offset height) (length content-lines))))
(let ((copy (copy-sequence content-lines)))
(cl-loop for line in window-lines
for index from offset
do (setf (nth index copy) line))
(setq state (plist-put state :content-lines copy))))
state))))))
(defun ebox--sync-scroll-state-paint-lines-for-node
(buffer node changed-keys report)
"Synchronize scroll cached lines for NODE paint-only CHANGED-KEYS.
This mirrors buffer paint patches by editing cached line text properties rather
than rerendering NODE, keeping GUI dynamic updates bounded."
(when-let* ((surfaces (and (cl-every
(lambda (key)
(memq key ebox--paint-style-signature-keys))
changed-keys)
(ebox--paint-text-property-surfaces
node changed-keys)))
(_ (ebox-buffer--paint-surfaces-safe-for-node-p
node surfaces)))
(let* ((region-ids (ebox--node-all-region-ids node))
(index-stable
(cl-every (lambda (spec)
(not (plist-get spec :remove-role)))
surfaces))
patched-region-ids
patched-count
span-count)
(catch 'failed
(maphash
(lambda (scroll-region-id state)
(let ((covering-lazy-state
(and (plist-get state :materialize-content-lines)
(not (plist-get state :content-lines-complete-p))
(ebox--scroll-state-covers-node-p
buffer state node))))
(let* ((content-lines (plist-get state :content-lines))
(content-spans
(and content-lines
(ebox--scroll-hot-content-line-spans
state region-ids))))
(if (not content-spans)
(when covering-lazy-state
(setq state
(plist-put state :lazy-scroll-prefix-dirty t))
(puthash scroll-region-id state
ebox--scroll-global-state))
(let* ((content-patch
(ebox--scroll-paint-lines-for-region
content-lines region-ids surfaces content-spans))
(rendered-lines
(plist-get state :rendered-content-lines))
(rendered-spans
(and rendered-lines
(ebox--scroll-hot-rendered-line-spans
state region-ids content-spans)))
(rendered-patch
(and rendered-lines
rendered-spans
(ebox--scroll-paint-lines-for-region
rendered-lines region-ids surfaces
rendered-spans))))
(if (not (and content-patch
(or (not rendered-lines)
rendered-patch)))
;; A surface the cached lines cannot express
;; yet (for example enabling a border whose
;; role span does not exist in the cache)
;; already patched the published buffer
;; through the inferring buffer patcher. The
;; buffer is the display truth and the paint
;; only touched rows inside the visible
;; window, so adopting the window back into
;; the cache is exact; only when even
;; adoption is impossible does the fallback
;; take over.
(let ((adopted
(ebox--scroll-state-adopt-buffer-window-lines
buffer scroll-region-id state)))
(unless adopted
(throw 'failed nil))
(setq adopted
(plist-put adopted :buffer buffer))
(puthash scroll-region-id adopted
ebox--scroll-global-state)
(cl-pushnew scroll-region-id
patched-region-ids
:test #'equal)
(setq patched-count
(1+ (or patched-count 0))))
(setq state
(if index-stable
(let ((state
(plist-put
state :content-lines
(plist-get content-patch
:lines))))
(if rendered-lines
(plist-put
state :rendered-content-lines
(plist-get rendered-patch
:lines))
state))
(ebox--scroll-state-patch-lines
state
(plist-get content-patch :lines)
(plist-get content-patch :spans)
(and rendered-lines
(plist-get rendered-patch :lines))
(and rendered-lines
(plist-get rendered-patch :spans))
region-ids)))
(setq state (plist-put state :buffer buffer))
;; Restore exact representational identity
;; between the published window and its visible
;; cache slice so the settled cache replays
;; exactly what is on screen and verification
;; stays on the raw fast path.
(setq state
(or (ebox--scroll-state-adopt-buffer-window-lines
buffer scroll-region-id state)
state))
(puthash scroll-region-id state
ebox--scroll-global-state)
(cl-pushnew scroll-region-id patched-region-ids
:test #'equal)
(setq patched-count (1+ (or patched-count 0)))
(setq span-count
(+ (or span-count 0)
(length
(plist-get content-patch
:spans))))))))))
ebox--scroll-global-state)
(when patched-count
;; Pure property patches move no buffer text, so existing
;; marker windows stay anchored to the same positions and the
;; full-buffer marker rebuild is normally unnecessary. Verify
;; first; rebuild one marker generation and re-verify only
;; when a patched window no longer proves healthy (for
;; example after a role-removing patch replaced cached line
;; structure). Existing markers are never cleared without a
;; replacement, so the live window cannot detach.
(let ((refreshed 0))
(cl-flet ((windows-healthy-p ()
(cl-every
(lambda (scroll-region-id)
(when-let ((state
(ebox--scroll-get-state
scroll-region-id)))
(ebox--scroll-state-window-markers-healthy-p
buffer scroll-region-id state)))
patched-region-ids)))
(unless (windows-healthy-p)
(ebox--refresh-buffer-scroll-content-markers buffer)
(setq refreshed (length patched-region-ids))
(unless (windows-healthy-p)
(throw 'failed nil))))
(setq report
(append report
(list :scroll-state-paint-sync t
:scroll-state-sync-count patched-count
:scroll-state-sync-span-count
(or span-count 0)
:scroll-state-index-preserved index-stable
:scroll-state-window-marker-refresh-count
refreshed
:scroll-state-window-markers-invalidated
nil)))))
report))))
(defun ebox--sync-scroll-state-lines-for-node
(buffer region-id node changed-keys report)
"Synchronize cached scroll lines for updated NODE in BUFFER.
Ordinary incremental patching mutates the visible buffer. Scroll containers
also keep their own cached content lines, so those lines must be updated or a
later scroll-window replacement can replay stale content."
(or (ebox--sync-scroll-state-paint-lines-for-node
buffer node changed-keys report)
(ebox--sync-scroll-state-lines-for-node-fallback
buffer region-id node changed-keys report)))
;;; ============================================================
;;; Scroll Interaction Commands
;;; ============================================================
(defun ebox--region-id-at-point ()
"Get region-id at current point by checking ebox-content property."
(get-text-property (point) 'ebox-content))
(defun ebox--scroll-region-ids-in-extents-at-pos (pos)
"Return scroll region ids whose live extents contain POS, inner first."
(let (candidates)
(maphash
(lambda (region-id _state)
(when-let ((extents (ebox--live-box-extents region-id)))
(let ((start (car extents))
(end (cdr extents)))
(when (and (markerp start)
(markerp end)
(eq (marker-buffer start) (current-buffer))
(<= (marker-position start) pos)
(< pos (marker-position end)))
(push (cons region-id
(- (marker-position end)
(marker-position start)))
candidates)))))
ebox--scroll-global-state)
(mapcar #'car
(sort candidates
(lambda (a b) (< (cdr a) (cdr b)))))))
(defun ebox--scroll-region-ids-in-buffer-outer-first ()
"Return scroll region ids in the current buffer from outer to inner."
(let (candidates)
(maphash
(lambda (region-id _state)
(when-let ((extents (ebox--live-box-extents region-id)))
(let ((start (car extents))
(end (cdr extents)))
(when (and (markerp start)
(markerp end)
(eq (marker-buffer start) (current-buffer)))
(push (cons region-id
(- (marker-position end)
(marker-position start)))
candidates)))))
ebox--scroll-global-state)
(mapcar #'car
(sort candidates
(lambda (a b) (> (cdr a) (cdr b)))))))
(defun ebox--scroll-region-ids-at-pos (pos)
"Return scroll candidate region ids at POS from inner to outer."
(let (ids)
(cl-labels ((add (region-id)
(when (and region-id
(not (member region-id ids)))
(setq ids (append ids (list region-id))))))
(add (get-text-property pos 'ebox-content))
(dolist (region-id (get-text-property pos 'ebox-content-owners))
(add region-id))
(add (get-text-property pos 'ebox-content-owner))
(dolist (entry ebox-region-types)
(unless (memq (cdr entry) '(ebox-content ebox-content-owner))
(add (get-text-property pos (cdr entry)))))
(unless (cl-some #'ebox--scroll-get-state ids)
(dolist (region-id (ebox--scroll-region-ids-in-extents-at-pos pos))
(add region-id))))
ids))
(defun ebox--scroll-region-ids-at-point ()
"Return scroll candidate region ids at point from inner to outer."
(when (< (point-min) (point-max))
(ebox--scroll-region-ids-at-pos
(min (max (point) (point-min))
(1- (point-max))))))
(defun ebox--scroll-refresh-dirty-prefix-for-offset
(region-id state desired-offset)
"Refresh dirty lazy STATE through REGION-ID's next visible window.
Geometry updates can invalidate cached prefix lines without changing their
length. Refresh them before any scroll, even when DESIRED-OFFSET remains
inside the old prefix, so a line-slide never mixes two layout versions."
(if (not (plist-get state :lazy-scroll-prefix-dirty))
state
(let* ((scroll-offset (or (plist-get state :scroll-offset) 0))
(content-height (max 0 (or (plist-get state :content-height) 0)))
(required-lines
(+ (max 0 scroll-offset desired-offset) content-height)))
(ebox--scroll-state-ensure-prefix-lines
region-id state required-lines t))))
(defun ebox--scroll-region-by (region-id delta &optional prefix-budget-lines)
"Scroll REGION-ID content by DELTA lines when it has scroll capacity.
Return the consumed line delta, `pending' for lazy cache misses, or nil.
PREFIX-BUDGET-LINES caps synchronous lazy-prefix rendering for this call."
(when-let ((state (and region-id (ebox--scroll-get-state region-id))))
(catch 'native-prefix-pending
(when (and (plist-get state :native-reflow-materialize-p)
(plist-get state :materialize-content-lines))
(setq state (ebox--scroll-state-materialize-lines region-id state)))
(when (plist-get state :native-reflow-target-prefix-p)
(let* ((scroll-offset (or (plist-get state :scroll-offset) 0))
(content-height
(max 0 (or (plist-get state :content-height) 0)))
(required-lines
(+ (max 0 scroll-offset (+ scroll-offset delta))
content-height)))
(if (ebox--scroll-sync-prefix-render-p)
(setq state
(ebox--scroll-state-ensure-prefix-lines
region-id state required-lines t))
;; The exact native window is already visible. Preserve it and
;; let bounded idle slices prepare the target-width cache instead
;; of freezing the GUI on a first wheel event.
(setq state
(plist-put state :cache-miss-prefetch-target-lines
required-lines))
(puthash region-id state ebox--scroll-global-state)
(ebox--scroll-schedule-idle-prefetch region-id 0)
(throw 'native-prefix-pending 'pending))))
(let* ((scroll-offset (or (plist-get state :scroll-offset) 0))
(desired-offset (+ scroll-offset delta))
(dirty-prefix-p (plist-get state :lazy-scroll-prefix-dirty))
refresh-window-p)
(setq state
(ebox--scroll-refresh-dirty-prefix-for-offset
region-id state desired-offset))
(setq refresh-window-p
(or dirty-prefix-p
(plist-get state :lazy-scroll-window-refresh-required)))
(if (plist-get state :lazy-scroll-prefix-dirty)
'pending
(when (and (> delta 0)
(or prefix-budget-lines
(and (ebox--scroll-sync-prefix-render-p)
(<= delta
(ebox--scroll-prefetch-slice-lines)))))
(setq state
(ebox--scroll-ensure-bounded-prefix-for-offset
region-id state desired-offset prefix-budget-lines)))
(setq refresh-window-p
(or refresh-window-p
(plist-get state :lazy-scroll-window-refresh-required)))
(let* ((content-lines (plist-get state :content-lines))
(content-height (plist-get state :content-height))
(max-offset (max 0 (- (length content-lines) content-height)))
(cache-miss-p
(and (> delta 0)
(> desired-offset max-offset)
(plist-get state :render-content-prefix)
(not (plist-get state :content-lines-complete-p))))
(new-offset (max 0 (min max-offset desired-offset))))
(when cache-miss-p
(setq state
(ebox--scroll-schedule-cache-miss-prefetch
region-id state)))
(cond
((and (= new-offset scroll-offset) refresh-window-p)
(let* ((new-visible-lines
(seq-subseq content-lines
new-offset
(min (+ new-offset content-height)
(length content-lines))))
(inhibit-read-only t)
(updated-spans
(ebox--scroll-update-content
region-id new-visible-lines nil))
(all-spans-updated
(and updated-spans
(cl-every #'identity updated-spans))))
(when (or all-spans-updated
(ebox--scroll-replace-rendered-window region-id state)
(ebox--scroll-rerender-region region-id))
(when-let ((current-state
(ebox--scroll-get-state region-id)))
(puthash
region-id
(ebox--plist-remove
current-state :lazy-scroll-window-refresh-required)
ebox--scroll-global-state))
(unless (gethash region-id ebox--smooth-scroll-state-table)
(ebox--scroll-schedule-idle-prefetch region-id))
'refreshed)))
((= new-offset scroll-offset)
(and cache-miss-p 'pending))
(t
(let ((old-offset scroll-offset)
(box (plist-get state :box))
(buffer (ebox--scroll-state-buffer state)))
(when box
(ebox-put box :scroll-offset new-offset)
(when buffer
(ebox--invalidate-buffer-render-signatures
buffer (ebox--ensure-node-id box))))
(plist-put state :scroll-offset new-offset)
(let* ((new-visible-lines
(seq-subseq content-lines
new-offset
(min (+ new-offset content-height)
(length content-lines))))
(inhibit-read-only t)
(updated-spans
(ebox--scroll-update-content
region-id new-visible-lines
(unless refresh-window-p old-offset)))
(all-spans-updated
(and updated-spans
(cl-every #'identity updated-spans))))
(if (or all-spans-updated
(ebox--scroll-replace-rendered-window region-id state)
(ebox--scroll-rerender-region region-id))
(progn
(when refresh-window-p
(when-let ((current-state
(ebox--scroll-get-state region-id)))
(puthash
region-id
(ebox--plist-remove
current-state
:lazy-scroll-window-refresh-required)
ebox--scroll-global-state)))
(unless (gethash region-id
ebox--smooth-scroll-state-table)
(ebox--scroll-schedule-idle-prefetch region-id))
(- new-offset old-offset))
(when box
(ebox-put box :scroll-offset old-offset))
(plist-put state :scroll-offset old-offset)
nil)))))))))))
(defun ebox--scroll-progress-p (result)
"Return non-nil when RESULT represents consumed or pending scroll work."
(or (memq result '(pending refreshed))
(and (numberp result)
(/= result 0))))
(defun ebox--scroll-redisplay-after-tick ()
"Flush GUI redisplay after an asynchronous scroll tick."
(unless noninteractive
(redisplay t)))
(defun ebox--scroll-rerender-region (region-id)
"Rerender scroll REGION-ID when direct content marker updates are unavailable."
(when-let* ((extents (ebox--live-box-extents region-id))
(start-marker (car extents))
(buffer (marker-buffer start-marker)))
(condition-case nil
(progn
(ebox--with-buffer-render-context buffer
(ebox--rerender-box region-id))
(when (buffer-live-p buffer)
(ebox--refresh-buffer-scroll-content-markers buffer)
(ebox-buffer-refresh-region-role-spans buffer)
(ebox--refresh-buffer-layout-snapshots buffer))
t)
(error nil))))
(defun ebox--scroll-decorate-rendered-window-lines (lines box region-id)
"Apply fixed scroll viewport chrome from BOX to rendered LINES."
(let* ((padding-top (floor (ebox-get box :padding-top-height)))
(padding-bottom (floor (ebox-get box :padding-bottom-height)))
(content-pixel (ebox--content-pixel box))
(color (ebox-get box :color))
(bgcolor (ebox-get box :bgcolor)))
(cl-labels
((style-line (line)
(ebox--propertize-colors line color bgcolor))
(padding-lines (height property)
(when (> height 0)
(mapcar #'style-line
(ebox-string-lines
(ebox--propertize-region
(or (ebox--pixel-blank content-pixel height) "")
property region-id))))))
(let ((lines (append (padding-lines padding-top 'ebox-pt)
;; These are already fully styled when
;; `:rendered-content-lines' is produced during the
;; original render. Reapplying faces to every
;; complex child-layout line on each wheel tick is
;; expensive enough to freeze GUI Emacs.
(mapcar #'copy-sequence lines)
(padding-lines padding-bottom 'ebox-pb))))
(when (and lines (ebox-get box :border-top-p))
(let ((line (ebox--propertize-overline
(car lines)
(ebox-get box :border-top-color))))
(add-text-properties 0 (length line) `(ebox-bt ,region-id) line)
(setcar lines line)))
(when (and lines (ebox-get box :border-bottom-p))
(let ((line (ebox--propertize-underline
(car (last lines))
(ebox-get box :border-bottom-color))))
(add-text-properties 0 (length line) `(ebox-bb ,region-id) line)
(setcar (last lines) line)))
lines))))
(defun ebox--render-scroll-state-window (state region-id)
"Render scroll STATE's current visible window for REGION-ID.
This uses the already formatted `:content-lines' stored in STATE, so scrolling
child-layout content does not need to rerender the whole source tree."
(setq state (ebox--scroll-state-materialize-lines region-id state))
(when-let* ((box (plist-get state :box))
(content-lines (plist-get state :content-lines))
(content-height (plist-get state :content-height)))
(let* ((text-height (length content-lines))
(rendered-content-lines (plist-get state :rendered-content-lines))
(max-offset (max 0 (- text-height content-height)))
(scroll-offset (max 0 (min max-offset
(or (plist-get state :scroll-offset)
0)))))
(if rendered-content-lines
(ebox-lines-join
(ebox--scroll-decorate-rendered-window-lines
(seq-subseq rendered-content-lines
scroll-offset
(min (+ scroll-offset content-height) text-height))
box region-id))
(let* (
(content-pixel (ebox--content-pixel box))
(padding-left (ebox-get box :padding-left-pixel))
(padding-right (ebox-get box :padding-right-pixel))
(padding-top (floor (ebox-get box :padding-top-height)))
(padding-bottom (floor (ebox-get box :padding-bottom-height)))
(margin-left (ebox-get box :margin-left-pixel))
(margin-right (ebox-get box :margin-right-pixel))
(margin-top (floor (ebox-get box :margin-top-height)))
(margin-bottom (floor (ebox-get box :margin-bottom-height)))
(border-left-pixel (ebox-get box :border-left-pixel))
(border-left-color (ebox-get box :border-left-color))
(border-right-pixel (ebox-get box :border-right-pixel))
(border-right-color (ebox-get box :border-right-color))
(border-top-p (ebox-get box :border-top-p))
(border-top-color (ebox-get box :border-top-color))
(border-bottom-p (ebox-get box :border-bottom-p))
(border-bottom-color (ebox-get box :border-bottom-color))
(color (ebox-get box :color))
(bgcolor (ebox-get box :bgcolor))
(inner-height (+ content-height padding-top padding-bottom))
(padding-line-filler (ebox-pixel-space content-pixel))
(visible-lines
(seq-subseq content-lines
scroll-offset
(min (+ scroll-offset content-height) text-height)))
(result
(ebox-lines-join
(cl-loop for line in visible-lines
for idx from 0
collect (ebox--propertize-content-line
line region-id idx
padding-line-filler)))))
(setq result
(ebox--lines-align-vertical
result content-height (ebox-get box :vertical-align)))
(setq result
(ebox--maplines
(lambda (line)
(if (and (string-empty-p (string-trim line))
(not (ebox--line-has-non-content-properties-p line)))
(let ((content-id (get-text-property 0 'ebox-content line))
(content-idx (get-text-property 0 'ebox-content-idx line)))
(if content-id
(propertize padding-line-filler
'ebox-content content-id
'ebox-content-idx content-idx
'ebox-content-owner region-id)
(propertize padding-line-filler
'ebox-content region-id
'ebox-content-idx 0
'ebox-content-owner region-id)))
line))
result))
(when (or (> padding-top 0) (> padding-bottom 0))
(let ((pt-str (when (> padding-top 0)
(ebox--propertize-region
(ebox--pixel-blank content-pixel padding-top)
'ebox-pt region-id)))
(pb-str (when (> padding-bottom 0)
(ebox--propertize-region
(ebox--pixel-blank content-pixel padding-bottom)
'ebox-pb region-id))))
(setq result (ebox--lines-stack-vertical pt-str result pb-str))))
(let ((pl-str (when (> padding-left 0)
(ebox--propertize-region
(ebox--pixel-blank padding-left inner-height)
'ebox-pl region-id)))
(pr-str (when (> padding-right 0)
(ebox--propertize-region
(ebox--pixel-blank padding-right inner-height)
'ebox-pr region-id))))
(when (or pl-str pr-str)
(setq result (ebox--lines-concat-horizontal pl-str result pr-str))))
(when (or color bgcolor)
(setq result
(ebox--maplines
(lambda (line)
(ebox--propertize-colors line color bgcolor))
result)))
(when (or (> border-left-pixel 0) (> border-right-pixel 0))
(let ((bl-str (when (> border-left-pixel 0)
(ebox--propertize-region
(ebox--pixel-border border-left-pixel
inner-height border-left-color)
'ebox-bl region-id)))
(br-str (when (> border-right-pixel 0)
(ebox--propertize-region
(ebox--pixel-border border-right-pixel
inner-height border-right-color)
'ebox-br region-id))))
(setq result (ebox--lines-concat-horizontal bl-str result br-str))))
(when (or border-top-p border-bottom-p)
(let ((lines (ebox-string-lines result)))
(when border-top-p
(setf (car lines)
(propertize
(ebox--propertize-overline (car lines) border-top-color)
'ebox-bt region-id)))
(when border-bottom-p
(setf (car (last lines))
(propertize
(ebox--propertize-underline
(car (last lines)) border-bottom-color)
'ebox-bb region-id)))
(setq result (ebox-lines-join lines))))
;; Visibility hides the box's own ink (content glyphs, colors,
;; borders) after assembly; margins below stay blank either way.
(when (eq (ebox-get box :visibility) 'hidden)
(setq result (ebox--apply-hidden-visibility result)))
(when (or (> margin-left 0) (> margin-right 0))
(let* ((box-height (ebox-string-height result))
(ml-str (when (> margin-left 0)
(ebox--propertize-region
(ebox--pixel-blank margin-left box-height)
'ebox-ml region-id)))
(mr-str (when (> margin-right 0)
(ebox--propertize-region
(ebox--pixel-blank margin-right box-height)
'ebox-mr region-id))))
(setq result (ebox--lines-concat-horizontal ml-str result mr-str))))
(when (or (> margin-top 0) (> margin-bottom 0))
(let ((total-pixel-width (ebox--total-pixel box)))
(let ((mt-str (when (> margin-top 0)
(ebox--propertize-region
(ebox--pixel-blank total-pixel-width margin-top)
'ebox-mt region-id)))
(mb-str (when (> margin-bottom 0)
(ebox--propertize-region
(ebox--pixel-blank total-pixel-width margin-bottom)
'ebox-mb region-id))))
(setq result (ebox--lines-stack-vertical mt-str result mb-str)))))
result)))))
(defun ebox--scroll-replace-rendered-window (region-id state)
"Patch scroll REGION-ID by replacing its fixed rendered viewport from STATE."
(when-let* ((extents (ebox--live-box-extents region-id))
(start-marker (car extents))
(end-marker (cdr extents))
(buffer (marker-buffer start-marker))
((eq buffer (marker-buffer end-marker)))
(rendered (ebox--render-scroll-state-window state region-id)))
(with-current-buffer buffer
(let* ((start (marker-position start-marker))
(end (marker-position end-marker))
(old-height (count-lines start end))
(new-height (length (ebox-string-lines rendered)))
(window-states
(mapcar (lambda (window)
(list :window window
:start (window-start window)
:point (window-point window)
:hscroll (window-hscroll window)))
(get-buffer-window-list buffer nil t)))
(inhibit-read-only t))
(when (= old-height new-height)
(save-excursion
(goto-char start)
(delete-region start end)
(insert rendered)
(ebox--scroll-store-rendered-window-extents
buffer region-id state start (point) new-height))
(dolist (window-state window-states)
(let ((window (plist-get window-state :window)))
(when (and (window-live-p window)
(eq (window-buffer window) buffer))
(set-window-start
window
(min (plist-get window-state :start) (point-max))
t)
(set-window-point
window
(min (plist-get window-state :point) (point-max)))
(set-window-hscroll
window (plist-get window-state :hscroll)))))
t)))))
(defun ebox--scroll-first-region-by (region-ids delta)
"Scroll the first region in REGION-IDS that can consume DELTA."
(catch 'scrolled
(dolist (region-id region-ids)
(when (ebox--scroll-progress-p
(ebox--scroll-region-by region-id delta))
(throw 'scrolled t)))
nil))
(defvar ebox--smooth-scroll-state-table (make-hash-table :test 'equal)
"Region-keyed pending smooth wheel scroll state.")
(defun ebox--scrollable-region-p (region-id delta)
"Return non-nil when REGION-ID can consume DELTA lines."
(when-let ((state (and region-id (ebox--scroll-get-state region-id))))
(let* ((scroll-offset (or (plist-get state :scroll-offset) 0))
(content-lines (plist-get state :content-lines))
(content-height (plist-get state :content-height))
(max-offset (max 0 (- (length content-lines) content-height)))
(new-offset (max 0 (min max-offset (+ scroll-offset delta)))))
(or (and (/= delta 0)
(plist-get state :native-reflow-target-prefix-p))
(and (> delta 0)
(plist-get state :render-content-prefix)
(not (plist-get state :content-lines-complete-p)))
(and (> max-offset 0)
(/= new-offset scroll-offset))))))
(defun ebox--first-scrollable-region (region-ids delta)
"Return the first region in REGION-IDS that can consume DELTA."
(cl-find-if (lambda (region-id)
(ebox--scrollable-region-p region-id delta))
region-ids))
(defun ebox--smooth-scroll-stop (region-id)
"Stop pending smooth wheel scroll animation for REGION-ID."
(when-let ((entry (gethash region-id ebox--smooth-scroll-state-table)))
(when-let ((timer (plist-get entry :timer)))
(cancel-timer timer))
(remhash region-id ebox--smooth-scroll-state-table)
(ebox--deferred-render-gc-schedule-restore)
(ebox--scroll-schedule-idle-prefetch region-id)))
(defun ebox--smooth-scroll-pause-for-prefetch
(region-id entry pending)
"Pause REGION-ID smooth scroll ENTRY until lazy prefetch extends the cache."
(when-let ((timer (plist-get entry :timer)))
(when (timerp timer)
(cancel-timer timer)))
(plist-put entry :timer nil)
(plist-put entry :pending pending)
(plist-put entry :waiting-prefetch t)
(puthash region-id entry ebox--smooth-scroll-state-table))
(defun ebox--smooth-scroll-clamp-pending (region-id pending)
"Clamp PENDING lines to REGION-ID's remaining scrollable range."
(if-let ((state (and region-id (ebox--scroll-get-state region-id))))
(let* ((scroll-offset (or (plist-get state :scroll-offset) 0))
(lazy-forward
(and (> pending 0)
(plist-get state :render-content-prefix)
(not (plist-get state :content-lines-complete-p))))
(content-line-count (length (plist-get state :content-lines)))
(content-height (plist-get state :content-height))
(max-offset (max 0 (- content-line-count content-height))))
(if lazy-forward
pending
(max (- scroll-offset)
(min (- max-offset scroll-offset)
pending))))
0))
(defun ebox--smooth-scroll-tick-magnitude (pending)
"Return how many lines one smooth tick should consume from PENDING."
(let* ((pending (abs pending))
(base (max 1 ebox-wheel-smooth-scroll-lines-per-tick))
(target (max 1 ebox-wheel-smooth-scroll-target-ticks))
(adaptive (ceiling pending target)))
(min pending (max base adaptive))))
(defun ebox--smooth-scroll-lazy-boundary-p (region-id delta)
"Return non-nil when DELTA would cross REGION-ID's lazy prefix boundary."
(when-let ((state (and (> delta 0)
(ebox--scroll-get-state region-id))))
(let* ((scroll-offset (or (plist-get state :scroll-offset) 0))
(content-lines (plist-get state :content-lines))
(content-height (or (plist-get state :content-height) 0))
(max-offset (max 0 (- (length content-lines) content-height))))
(and (> (+ scroll-offset delta) max-offset)
(plist-get state :render-content-prefix)
(not (plist-get state :content-lines-complete-p))))))
(defun ebox--smooth-scroll-tick (region-id)
"Run one smooth wheel scroll tick for REGION-ID."
(let* ((entry (gethash region-id ebox--smooth-scroll-state-table))
(pending (and entry
(ebox--smooth-scroll-clamp-pending
region-id
(or (plist-get entry :pending) 0)))))
(cond
((or (not entry) (= pending 0) (not (ebox--scroll-get-state region-id)))
(ebox--smooth-scroll-stop region-id))
(t
(let* ((magnitude (ebox--smooth-scroll-tick-magnitude pending))
(delta (if (> pending 0) magnitude (- magnitude))))
(when (ebox--smooth-scroll-lazy-boundary-p region-id delta)
(setq magnitude
(min magnitude
(max 1 ebox-wheel-smooth-scroll-lines-per-tick)))
(setq delta (if (> pending 0) magnitude (- magnitude))))
(let* ((state-before (ebox--scroll-get-state region-id))
(offset-before (or (plist-get state-before :scroll-offset) 0))
(result (ebox--scroll-region-by region-id delta magnitude))
(state-after (ebox--scroll-get-state region-id))
(offset-after (or (plist-get state-after :scroll-offset)
offset-before))
(consumed (- offset-after offset-before)))
(cond
((and (ebox--scroll-progress-p result)
(/= consumed 0))
(ebox--scroll-redisplay-after-tick)
(let ((remaining (- pending consumed)))
(if (= remaining 0)
(ebox--smooth-scroll-stop region-id)
(plist-put entry :pending remaining)
(puthash region-id entry ebox--smooth-scroll-state-table))))
((eq result 'pending)
(ebox--smooth-scroll-pause-for-prefetch
region-id entry pending))
(t
(ebox--smooth-scroll-stop region-id)))))))))
(defun ebox--smooth-scroll-region-by (region-id delta)
"Animate wheel scroll REGION-ID by DELTA lines."
(let* ((existing (gethash region-id ebox--smooth-scroll-state-table))
(entry (or existing (list :pending 0 :timer nil)))
(had-timer (timerp (plist-get entry :timer)))
(pending (ebox--smooth-scroll-clamp-pending
region-id
(+ (or (plist-get entry :pending) 0) delta))))
(if (= pending 0)
(progn
(ebox--smooth-scroll-stop region-id)
nil)
;; Enter the deferred-GC lease once per animation: entry insertion
;; below pairs with the single schedule-restore in
;; `ebox--smooth-scroll-stop'. Wheel events that only add distance
;; to an active animation must not deepen the lease, or the depth
;; never returns to zero and the raised GC thresholds leak for the
;; rest of the session.
(unless existing
(ebox--deferred-render-gc-enter))
(ebox--scroll-cancel-idle-prefetch region-id)
(plist-put entry :pending pending)
(puthash region-id entry ebox--smooth-scroll-state-table)
;; Move the first tick immediately so the UI responds to the initial
;; event. While a timer is already active, new wheel events only update
;; pending distance; the timer coalesces them into frame-sized edits.
(unless had-timer
(ebox--smooth-scroll-tick region-id))
(when-let ((entry (gethash region-id ebox--smooth-scroll-state-table)))
(unless (timerp (plist-get entry :timer))
(plist-put
entry :timer
(run-at-time ebox-wheel-smooth-scroll-interval
ebox-wheel-smooth-scroll-interval
#'ebox--smooth-scroll-tick region-id))
(puthash region-id entry ebox--smooth-scroll-state-table)))
t)))
(defun ebox--smooth-scroll-clear-regions (region-ids)
"Stop smooth wheel scroll timers for REGION-IDS."
(dolist (region-id region-ids)
(ebox--smooth-scroll-stop region-id)))
(defun ebox--scroll-by (delta fallback)
"Scroll box content by DELTA lines. Positive = down, negative = up.
FALLBACK is the Emacs scroll command to call when ebox cannot scroll."
(if (ebox--scroll-first-region-by
(ebox--scroll-region-ids-at-point)
delta)
t
(funcall fallback 1)))
(defun ebox--scroll-page-lines (&optional arg)
"Return the number of content lines to move for a page scroll ARG."
(if arg
(prefix-numeric-value arg)
(max ebox-scroll-step
(- (window-body-height nil) next-screen-context-lines))))
(defun ebox--wheel-region-ids (event)
"Return ebox scroll candidate region ids under mouse wheel EVENT."
(when-let* ((start (ignore-errors (event-start event)))
(window (posn-window start))
((window-live-p window)))
(with-current-buffer (window-buffer window)
(let* ((position (posn-point start))
(ids
(when (and (integer-or-marker-p position)
(< (point-min) (point-max)))
(let ((pos (if (markerp position)
(marker-position position)
position)))
(ebox--scroll-region-ids-at-pos
(min (max pos (point-min))
(1- (point-max))))))))
(or ids
(ebox--scroll-region-ids-in-buffer-outer-first))))))
(defun ebox--wheel-region-id (event)
"Return the innermost ebox region id under mouse wheel EVENT, or nil."
(car (ebox--wheel-region-ids event)))
(defun ebox--wheel-scroll (event delta &optional arg smooth)
"Scroll an ebox region under EVENT by DELTA, or delegate to `mwheel-scroll'.
When SMOOTH is non-nil, animate the scroll over short line steps."
(let* ((region-ids (ebox--wheel-region-ids event))
(region-id (and smooth
ebox-wheel-smooth-scroll
(ebox--first-scrollable-region region-ids delta))))
(cond
(region-id
(ebox--smooth-scroll-region-by region-id delta))
((ebox--scroll-first-region-by region-ids delta)
t)
(t
(mwheel-scroll event arg)))))
(defun ebox-wheel-scroll-down (event &optional arg)
"Handle mouse wheel down EVENT in an ebox buffer.
Scrollable ebox content under the mouse consumes the event; otherwise delegate
to Emacs' normal `mwheel-scroll' so preview buffers keep native wheel speed."
(interactive "e\nP")
(ebox--wheel-scroll event ebox-wheel-scroll-step arg t))
(defun ebox-wheel-scroll-up (event &optional arg)
"Handle mouse wheel up EVENT in an ebox buffer.
Scrollable ebox content under the mouse consumes the event; otherwise delegate
to Emacs' normal `mwheel-scroll' so preview buffers keep native wheel speed."
(interactive "e\nP")
(ebox--wheel-scroll event (- ebox-wheel-scroll-step) arg t))
;;;###autoload
(defun ebox-scroll-down (&optional n)
"Scroll box content down by N lines.
When called from Lisp with nil N, use `ebox-scroll-step'."
(interactive "p")
(ebox--scroll-by (or n ebox-scroll-step) #'scroll-up))
;;;###autoload
(defun ebox-scroll-up (&optional n)
"Scroll box content up by N lines.
When called from Lisp with nil N, use `ebox-scroll-step'."
(interactive "p")
(ebox--scroll-by (- (or n ebox-scroll-step)) #'scroll-down))
;;;###autoload
(defun ebox-scroll-page-down (&optional arg)
"Scroll ebox content down by a page.
With prefix ARG, scroll by that many content lines."
(interactive "P")
(let ((delta (ebox--scroll-page-lines arg)))
(ebox--scroll-by
delta
(lambda (&optional _n)
(scroll-up-command arg)))))
;;;###autoload
(defun ebox-scroll-page-up (&optional arg)
"Scroll ebox content up by a page.
With prefix ARG, scroll by that many content lines."
(interactive "P")
(let ((delta (- (ebox--scroll-page-lines arg))))
(ebox--scroll-by
delta
(lambda (&optional _n)
(scroll-down-command arg)))))
(defun ebox--scroll-replace-content-marker (state region-id idx string)
"Replace visible scroll line IDX for REGION-ID using STATE markers."
(when-let* ((markers (plist-get state :content-span-markers))
((< idx (length markers)))
(span (aref markers idx))
((consp span))
(start-marker (car span))
(end-marker (cdr span))
((markerp start-marker))
((markerp end-marker))
(buffer (marker-buffer start-marker))
((eq buffer (marker-buffer end-marker)))
(start (marker-position start-marker))
(end (marker-position end-marker)))
(with-current-buffer buffer
(when (and (<= start end)
(or
(and
(eql (get-text-property start 'ebox-content) region-id)
(eql (get-text-property start 'ebox-content-idx) idx))
(eql (get-text-property start 'ebox-content-owner) region-id)
(member region-id
(get-text-property start 'ebox-content-owners))))
(let ((inhibit-read-only t)
(props (copy-sequence (text-properties-at start))))
(save-excursion
(let ((replacement
(ebox--scroll-propertize-visible-line
string region-id idx props)))
;; Let Emacs retain unchanged buffer intervals and markers, then
;; copy the replacement's exact per-character properties. A
;; delete/insert of the whole display-bearing span can make the
;; first GUI redisplay publish a torn flex row even when the
;; resulting buffer string is otherwise correct.
(replace-region-contents start end (lambda () replacement))
(ebox--copy-string-text-properties-to-buffer replacement start)
(goto-char (+ start (length replacement))))
(set-marker start-marker start)
(set-marker end-marker (point)))
(cons start (marker-position end-marker)))))))
(defun ebox--scroll-content-window (state line-count)
"Return marker-backed visible scroll window for STATE and LINE-COUNT.
The return value is (BUFFER START END ENTRIES). Each entry is
(IDX START-MARKER END-MARKER PROPS)."
(when-let ((markers (plist-get state :content-span-markers)))
(catch 'invalid
(let (buffer window-start window-end previous-end entries)
(dotimes (idx line-count)
(unless (< idx (length markers))
(throw 'invalid nil))
(let* ((span (aref markers idx))
(start-marker (and (consp span) (car span)))
(end-marker (and (consp span) (cdr span))))
(unless (and (markerp start-marker)
(markerp end-marker)
(marker-buffer start-marker)
(eq (marker-buffer start-marker)
(marker-buffer end-marker)))
(throw 'invalid nil))
(let ((start (marker-position start-marker))
(end (marker-position end-marker))
(span-buffer (marker-buffer start-marker)))
(unless (and (<= start end)
(or (not buffer) (eq buffer span-buffer))
(or (not previous-end)
(and (= start (1+ previous-end))
(with-current-buffer span-buffer
(eq (char-after previous-end) ?\n)))))
(throw 'invalid nil))
(unless (and (< start end)
(with-current-buffer span-buffer
(let ((region-id (plist-get state :region-id)))
(or (and (equal (get-text-property
start 'ebox-content)
region-id)
(eql (get-text-property
start 'ebox-content-idx)
idx))
(equal (get-text-property
start 'ebox-content-owner)
region-id)
(member region-id
(get-text-property
start 'ebox-content-owners))))))
(throw 'invalid nil))
(setq buffer span-buffer)
(unless window-start
(setq window-start start))
(setq window-end end
previous-end end)
(push (list idx start-marker end-marker
(with-current-buffer span-buffer
(copy-sequence (text-properties-at start))))
entries))))
(and buffer window-start window-end
(list buffer window-start window-end (nreverse entries)))))))
(defun ebox--scroll-propertize-visible-line (line region-id idx props)
"Return LINE with visible scroll marker PROPS for REGION-ID and IDX."
(let ((line (copy-sequence line)))
(if (text-property-not-all 0 (length line) 'ebox-content nil line)
(ebox--add-content-owner line region-id)
(let ((props (copy-sequence props)))
;; LINE already owns its formatted `display' runs. In particular,
;; centered content uses a width-bearing display space only at each
;; edge. Copying the old first character's display value over the
;; entire replacement line makes every glyph consume that width and
;; visibly tears apart neighboring flex boxes until a later rerender.
(setq props (ebox--plist-remove props 'display))
(setq props (plist-put props 'ebox-content region-id))
(setq props (plist-put props 'ebox-content-idx idx))
(when (> (length line) 0)
(add-text-properties 0 (length line) props line))
line))))
(defun ebox--scroll-replace-content-window (state region-id new-lines)
"Replace all visible scroll NEW-LINES for REGION-ID in one buffer edit."
(let ((state (plist-put (copy-sequence state) :region-id region-id)))
(pcase-let ((`(,buffer ,window-start ,window-end ,entries)
(ebox--scroll-content-window state (length new-lines))))
(when (and buffer entries)
(with-current-buffer buffer
(let ((inhibit-read-only t)
(line-count (length new-lines))
spans
marker-updates
replacement-lines
(offset 0))
(save-excursion
(cl-loop for line in new-lines
for entry in entries
for line-idx from 0
do
(pcase-let ((`(,idx ,start-marker ,end-marker ,props)
entry))
(let* ((replacement-line
(ebox--scroll-propertize-visible-line
line region-id idx props))
(line-start (+ window-start offset))
(line-end (+ line-start
(length replacement-line))))
(push replacement-line replacement-lines)
(push (list start-marker end-marker
line-start line-end)
marker-updates)
(push (cons line-start line-end) spans)
(setq offset
(+ offset (length replacement-line)
(if (< line-idx (1- line-count))
1
0))))))
(let ((replacement
(ebox-lines-join (nreverse replacement-lines))))
(replace-region-contents
window-start window-end (lambda () replacement))
(ebox--copy-string-text-properties-to-buffer
replacement window-start))
(dolist (update marker-updates)
(pcase-let ((`(,start-marker ,end-marker ,start ,end)
update))
(set-marker start-marker start)
(set-marker end-marker end))))
(let ((spans (nreverse spans)))
(ebox--scroll-state-clear-window-region-id-set
region-id (or (ebox--scroll-get-state region-id) state)
buffer)
spans)))))))
(defun ebox--scroll-chrome-free-rendered-window-p (box)
"Return non-nil when BOX's scroll viewport has no dynamic chrome rows."
(and (= (floor (ebox-get box :padding-top-height)) 0)
(= (floor (ebox-get box :padding-bottom-height)) 0)
(not (ebox-get box :border-top-p))
(not (ebox-get box :border-bottom-p))
(= (floor (ebox-get box :margin-top-height)) 0)
(= (floor (ebox-get box :margin-bottom-height)) 0)))
(defun ebox--scroll-state-window-extents (region-id state)
"Return marker-backed visible viewport extents for chrome-free scroll STATE.
The scroll state may own a lightweight rendered-window boundary marker pair.
For a chrome-free viewport those markers are the rendered box window, so they
are the most accurate source after viewport resize invalidates global extents.
Older content-span markers are kept as a fallback for freshly rendered buffers
and dynamic update marker refreshes."
(when-let* ((box (plist-get state :box))
((ebox--scroll-chrome-free-rendered-window-p box))
(content-height (plist-get state :content-height))
((> content-height 0)))
(let* ((start-marker (plist-get state :rendered-window-start-marker))
(end-marker (plist-get state :rendered-window-end-marker))
(line-count (plist-get state :rendered-window-line-count))
(buffer (and (markerp start-marker)
(marker-buffer start-marker))))
(or
(when (and buffer
(markerp end-marker)
(eq buffer (marker-buffer end-marker))
(integerp line-count)
(= line-count content-height)
(marker-position start-marker)
(marker-position end-marker)
(< (marker-position start-marker)
(marker-position end-marker)))
(cons start-marker end-marker))
(when-let ((markers (plist-get state :content-span-markers)))
(let (buffer start end)
(dolist (span (ebox--scroll-content-marker-spans markers))
(let* ((start-marker (car-safe span))
(end-marker (cdr-safe span))
(span-buffer (and (markerp start-marker)
(marker-buffer start-marker)))
(span-start (and (markerp start-marker)
(marker-position start-marker)))
(span-end (and (markerp end-marker)
(marker-position end-marker))))
(when (and (markerp start-marker)
(markerp end-marker)
span-buffer
(eq span-buffer (marker-buffer end-marker))
span-start
span-end
(< span-start span-end)
(or (not buffer) (eq buffer span-buffer)))
(setq buffer span-buffer)
(setq start (if start (min start span-start) span-start))
(setq end (if end (max end span-end) span-end)))))
(when (and buffer start end
(= (with-current-buffer buffer
(count-lines start end))
content-height))
(when-let ((stored
(ebox--scroll-store-rendered-window-extents
buffer region-id state start end content-height)))
(cons (plist-get stored :rendered-window-start-marker)
(plist-get stored
:rendered-window-end-marker))))))))))
(defun ebox--scroll-rendered-window-slide-preferred-p ()
"Return non-nil when rendered scroll windows should use line sliding.
Line sliding keeps scroll edits proportional to the consumed delta instead of
the viewport height. GUI and terminal frames both need this bounded edit path;
expensive hidden content should be prefetched asynchronously rather than paid
inside the scroll event."
t)
(defun ebox--scroll-capture-window-states (buffer)
"Return live window positions for BUFFER."
(mapcar (lambda (window)
(list :window window
:start (window-start window)
:point (window-point window)
:hscroll (window-hscroll window)))
(get-buffer-window-list buffer nil t)))
(defun ebox--scroll-lines-with-live-wrapper-context
(lines position region-id)
"Return cached LINES with wrapper context recovered at POSITION.
The live owner stack is inner-to-outer. Only owners after REGION-ID are outer
wrappers; copying an outgoing child's owners onto an entering line would make
section boundaries semantically stale. Each line is decorated independently
because complete box rendering leaves the separators inserted by
`ebox-lines-join' unpropertized."
(let* ((owners
(or (get-text-property position 'ebox-content-owners)
(when-let ((owner
(get-text-property position 'ebox-content-owner)))
(list owner))))
(owner-tail (member region-id owners)))
(when owner-tail
(mapcar
(lambda (line)
(let ((result
(ebox--string-with-preserved-wrapper-context
line (cdr owner-tail)
(ebox--string-content-owner-set line))))
(add-text-properties
0 (length result) (list 'ebox-scroll-window region-id) result)
result))
lines))))
(defun ebox--scroll-restore-window-states (buffer window-states)
"Restore WINDOW-STATES for live windows still showing BUFFER."
(dolist (window-state window-states)
(let ((window (plist-get window-state :window)))
(when (and (window-live-p window)
(eq (window-buffer window) buffer))
(set-window-start
window
(min (plist-get window-state :start) (point-max))
t)
(set-window-point
window
(min (plist-get window-state :point) (point-max)))
(set-window-hscroll
window (plist-get window-state :hscroll))))))
(defun ebox--scroll-replace-rendered-content-window-slide
(state region-id old-offset scroll-offset rendered-content-lines
content-height start-marker end-marker buffer)
"Slide a rendered scroll viewport by mutating only entering/leaving lines."
(when-let* (((integerp old-offset))
(delta (- scroll-offset old-offset))
((/= delta 0))
(step (abs delta))
((< step content-height))
(start (marker-position start-marker))
(end (marker-position end-marker)))
(let* ((line-count (length rendered-content-lines))
(entering-lines
(if (> delta 0)
(and (<= (+ scroll-offset content-height) line-count)
(seq-subseq rendered-content-lines
(+ old-offset content-height)
(+ scroll-offset content-height)))
(and (<= old-offset line-count)
(seq-subseq rendered-content-lines
scroll-offset old-offset)))))
(when (and entering-lines (= (length entering-lines) step))
(with-current-buffer buffer
(let* ((old-height (count-lines start end))
(window-states (ebox--scroll-capture-window-states buffer))
(inhibit-read-only t))
(when (= old-height content-height)
(when-let ((entering-lines
(ebox--scroll-lines-with-live-wrapper-context
entering-lines start region-id)))
(ebox--scroll-clear-content-markers state)
(setq state (plist-put state :content-span-markers nil))
(setq state
(plist-put state :content-window-region-id-set nil))
(setq state (plist-put state :buffer buffer))
(puthash region-id state ebox--scroll-global-state)
(save-excursion
(if (> delta 0)
(progn
(goto-char start)
(forward-line step)
(delete-region start (point))
(goto-char (marker-position end-marker))
(insert "\n")
(insert (ebox-lines-join entering-lines)))
(goto-char start)
(forward-line (- content-height step))
(let ((delete-start (if (> (point) start)
(1- (point))
(point))))
(delete-region
delete-start (marker-position end-marker)))
(goto-char start)
(insert (ebox-lines-join entering-lines))
(insert "\n")))
(let* ((spans (ebox--scroll-line-spans-in-window
start content-height))
(new-end (if spans (cdr (car (last spans))) start)))
(ebox--scroll-store-rendered-window-extents
buffer region-id state start new-end content-height)
(ebox--scroll-restore-window-states buffer window-states)
spans)))))))))
(defun ebox--scroll-replace-rendered-content-window
(state region-id &optional old-offset)
"Replace chrome-free rendered scroll viewport for REGION-ID from STATE."
(let ((marker-extents (ebox--scroll-state-window-extents region-id state)))
(when-let* ((rendered-content-lines
(plist-get state :rendered-content-lines))
(content-height (plist-get state :content-height))
(box (plist-get state :box))
((ebox--scroll-chrome-free-rendered-window-p box))
(extents (or marker-extents
(ebox--live-box-extents region-id)))
(start-marker (car extents))
(end-marker (cdr extents))
(buffer (marker-buffer start-marker))
((eq buffer (marker-buffer end-marker))))
(let* ((max-offset (max 0 (- (length rendered-content-lines)
content-height)))
(scroll-offset (max 0 (min max-offset
(or (plist-get state :scroll-offset)
0))))
(visible-lines
(seq-subseq rendered-content-lines
scroll-offset
(min (+ scroll-offset content-height)
(length rendered-content-lines)))))
(or (and marker-extents
(ebox--scroll-rendered-window-slide-preferred-p)
(ebox--scroll-replace-rendered-content-window-slide
state region-id old-offset scroll-offset rendered-content-lines
content-height start-marker end-marker buffer))
(with-current-buffer buffer
(let* ((start (marker-position start-marker))
(rendered
(when-let ((lines
(ebox--scroll-lines-with-live-wrapper-context
visible-lines start region-id)))
(ebox-lines-join lines)))
(end (marker-position end-marker))
(old-height (count-lines start end))
(new-height (length visible-lines))
(window-states
(ebox--scroll-capture-window-states buffer))
(inhibit-read-only t))
(when (and rendered (= old-height new-height))
(ebox--scroll-clear-content-markers state)
(setq state (plist-put state :content-span-markers nil))
(setq state
(plist-put state :content-window-region-id-set nil))
(setq state (plist-put state :buffer buffer))
(puthash region-id state ebox--scroll-global-state)
(save-excursion
(goto-char start)
(delete-region start end)
(insert rendered))
(let ((spans (ebox--scroll-line-spans-in-window
start new-height)))
(ebox--scroll-store-rendered-window-extents
buffer region-id state start
(if spans (cdr (car (last spans))) start)
new-height)
(ebox--scroll-restore-window-states buffer window-states)
spans)))))))))
(defun ebox--scroll-ancestor-horizontal-border-p (buffer region-id state)
"Return non-nil when REGION-ID has a bordered ancestor in BUFFER.
Horizontal ancestor borders are painted by viewport slot rather than by the
cached child line. Moving cached lines directly would move or drop that
position-dependent paint."
(when-let* (((buffer-live-p buffer))
(extents (or (ebox--scroll-state-window-extents region-id state)
(ebox--live-box-extents region-id)))
(start-marker (car extents))
((markerp start-marker))
((eq (marker-buffer start-marker) buffer))
(position (marker-position start-marker))
(owners
(with-current-buffer buffer
(or (get-text-property position 'ebox-content-owners)
(when-let ((owner
(get-text-property
position 'ebox-content-owner)))
(list owner)))))
(outer-owners (cdr (member region-id owners))))
(cl-some
(lambda (owner)
(when-let ((box (gethash owner ebox--region-box-table)))
(or (ebox-get box :border-top-p)
(ebox-get box :border-bottom-p))))
outer-owners)))
(defun ebox--scroll-rerender-root-window (buffer region-id)
"Rerender BUFFER and return REGION-ID's freshly published line spans."
(when (and (buffer-live-p buffer)
(ebox-buffer-apply-root-rerender buffer))
(when-let* ((state (ebox--scroll-get-state region-id))
(height (plist-get state :content-height))
(extents (ebox--scroll-state-window-extents region-id state))
(start-marker (car extents))
((eq (marker-buffer start-marker) buffer)))
(with-current-buffer buffer
(ebox--scroll-line-spans-in-window
(marker-position start-marker) height)))))
(defun ebox--scroll-update-content (region-id new-lines &optional old-offset)
"Update visible content lines for scroll REGION-ID with NEW-LINES.
This is a dedicated fast path for overflow='scroll' boxes whose outer box
geometry stays unchanged while only the visible window contents move."
(let* ((state (ebox--scroll-get-state region-id))
(buffer (or (and state (ebox--scroll-state-buffer state))
(current-buffer)))
(result
(or (and state
(ebox--scroll-ancestor-horizontal-border-p
buffer region-id state)
(ebox--scroll-rerender-root-window buffer region-id))
(and state
(ebox--scroll-replace-rendered-content-window
state region-id old-offset))
(and state
(ebox--scroll-replace-content-window
state region-id new-lines))
(when (or (not state)
(>= (length (ebox--scroll-content-marker-spans
(plist-get state :content-span-markers)))
(length new-lines)))
(cl-loop for line in new-lines
for idx from 0
collect
(or (and state
(ebox--scroll-replace-content-marker
state region-id idx line))
(ebox--region-replace-line
region-id idx line)))))))
(when (and result (buffer-live-p buffer))
;; Scroll replacement changes which runtime regions occupy otherwise
;; stable marker coordinates. Discard only the derived indexes here;
;; role spans and snapshot details are reconstructed lazily on demand.
(ebox--set-buffer-region-role-span-table buffer nil)
(ebox--invalidate-buffer-layout-snapshot-details buffer))
result))
(defun ebox--scroll-marker-spans-compatible-p (state new-lines)
"Return non-nil when NEW-LINES can replace STATE markers without shifting text."
(when-let ((markers (plist-get state :content-span-markers)))
(cl-loop for line in new-lines
for idx from 0
always
(and (< idx (length markers))
(when-let* ((span (aref markers idx))
((consp span))
(start-marker (car span))
(end-marker (cdr span))
((markerp start-marker))
((markerp end-marker))
((eq (marker-buffer start-marker)
(marker-buffer end-marker)))
(start (marker-position start-marker))
(end (marker-position end-marker)))
(= (- end start) (length line)))))))
(defun ebox--scroll-fast-path-report
(buffer region-id node-id new-spans changed-keys)
"Return an update report for a scroll-offset fast-path patch."
(when (and buffer node-id new-spans)
(ebox--update-report
region-id 'span-patch
:dirty-count 1
:patch-count 1
:patch-ops '(span-patch)
:owner-id node-id
:owner-ids (list node-id)
:owner-type 'box
:span-count (length new-spans)
:scroll-fast-path t
:dirty-kinds '(geometry)
:dirty-keys changed-keys)))
(defun ebox--scroll-transaction-report
(buffer region-id node-id span-count changed-keys)
"Return a report for an atomically synchronized scroll transaction."
(when (and buffer node-id)
(ebox--update-report
region-id 'span-patch
:dirty-count 1
:patch-count 1
:patch-ops '(span-patch)
:owner-id node-id
:owner-ids (list node-id)
:owner-type 'box
:span-count (or span-count 0)
:scroll-state-transaction t
:scroll-state-sync t
:scroll-state-sync-count 1
:dirty-kinds '(geometry)
:dirty-keys changed-keys)))
(defun ebox--scroll-offset-state-settled-p (region-id offset)
"Return non-nil when REGION-ID atomically exposes OFFSET."
(when-let ((state (ebox--scroll-get-state region-id)))
(and (= (or (plist-get state :scroll-offset) 0) offset)
(not (plist-get state :lazy-scroll-prefix-dirty))
(not (plist-get state :lazy-scroll-window-refresh-required)))))
(defun ebox--try-scroll-offset-fast-path
(buffer region-id changed-keys)
"Patch REGION-ID for a scroll-offset-only update in BUFFER.
Return an update report only after its runtime, cache, and window agree."
(when (and buffer
(equal changed-keys '(:scroll-offset)))
(when-let ((state (ebox--scroll-get-state region-id)))
(setq state (ebox--scroll-state-materialize-lines region-id state))
(when-let* ((box (plist-get state :box))
((eq (ebox-get box :overflow) 'scroll))
(content-lines (plist-get state :content-lines))
(content-height (plist-get state :content-height)))
(let* ((max-offset (max 0 (- (length content-lines) content-height)))
(scroll-offset (max 0 (min max-offset
(or (ebox-get box :scroll-offset)
0))))
(old-offset (or (plist-get state :scroll-offset) 0))
(visible-lines
(seq-subseq content-lines
scroll-offset
(min (+ scroll-offset content-height)
(length content-lines))))
(node-id (ebox--buffer-region-render-owner-node-id
buffer region-id))
(direct-eligible-p
(or (ebox--scroll-marker-spans-compatible-p
state visible-lines)
(and (null (plist-get state :content-span-markers))
(= (length visible-lines) content-height)
(plist-get state :rendered-content-lines)
(ebox--scroll-state-window-extents
region-id state))))
direct-report)
(when direct-eligible-p
(let ((old-state (copy-sequence state)))
(ebox-put box :scroll-offset scroll-offset)
(setq state (plist-put state :scroll-offset scroll-offset))
(setq state (plist-put state :box box))
;; `ebox--scroll-update-content' reads the shared state. Publish
;; the staged offset before replacing the window, then retain it
;; only when every visible span was replaced successfully.
(puthash region-id state ebox--scroll-global-state)
(with-current-buffer buffer
(let ((new-spans
(delq nil
(ebox--scroll-update-content
region-id visible-lines old-offset))))
(if new-spans
(progn
(when-let ((current-state
(ebox--scroll-get-state region-id)))
(puthash
region-id
(ebox--plist-remove
current-state
:lazy-scroll-window-refresh-required)
ebox--scroll-global-state))
(when (ebox--scroll-offset-state-settled-p
region-id scroll-offset)
(setq direct-report
(ebox--scroll-fast-path-report
buffer region-id node-id new-spans
changed-keys))))
(puthash region-id old-state
ebox--scroll-global-state)))))
(or direct-report
;; Marker slots can legitimately be incompatible after a large
;; jump or viewport reflow. Reuse the ordinary scroll engine so
;; it commits the offset and visible window together instead of
;; letting the generic cached-line patch split those states.
(let ((delta (- scroll-offset old-offset)))
(ebox--scroll-region-by region-id delta)
(if (ebox--scroll-offset-state-settled-p
region-id scroll-offset)
(ebox--scroll-transaction-report
buffer region-id node-id content-height changed-keys)
;; `ebox--scroll-region-by' rolls the box back on failure;
;; restore the requested runtime value for the conservative
;; planner that follows this failed transaction.
(ebox-put box :scroll-offset scroll-offset)
nil)))))))))
;;; ============================================================
;;; Region Update API
;;; ============================================================
(defconst ebox-region-types
'((content . ebox-content)
(content-owner . ebox-content-owner)
(pt . ebox-pt) (pb . ebox-pb) (pl . ebox-pl) (pr . ebox-pr)
(mt . ebox-mt) (mb . ebox-mb) (ml . ebox-ml) (mr . ebox-mr)
(bt . ebox-bt) (bb . ebox-bb) (bl . ebox-bl) (br . ebox-br))
"Mapping from region type symbol to text property name.")
(defsubst ebox--region-property (type)
"Get text property name for region TYPE."
(or (alist-get type ebox-region-types)
(error "Unknown region type: %S" type)))
(defun ebox--region-id-at-pos (pos)
"Return the owning ebox region-id at POS, if any."
(cl-loop for (_type . prop) in ebox-region-types
for region-id = (get-text-property pos prop)
when region-id return region-id))
(defun ebox--set-box-extents (region-id start end)
"Record REGION-ID extents from START to END using markers."
(let* ((existing (gethash region-id ebox--box-extents))
(start-marker (or (car-safe existing) (make-marker)))
(end-marker (or (cdr-safe existing) (make-marker))))
(set-marker-insertion-type start-marker nil)
(set-marker-insertion-type end-marker t)
(set-marker start-marker start)
(set-marker end-marker end)
(puthash region-id (cons start-marker end-marker) ebox--box-extents)))
(defun ebox--clear-buffer-extents (&optional buffer preserve-template)
"Remove cached extents whose markers belong to BUFFER.
Defaults to the current buffer.
When PRESERVE-TEMPLATE is non-nil, keep BUFFER's prepared numeric extent
template while stale live marker extents are removed."
(let ((buffer (or buffer (current-buffer)))
stale-ids)
(when (buffer-live-p buffer)
(with-current-buffer buffer
(unless preserve-template
(setq-local ebox--box-extent-template nil))))
(maphash
(lambda (region-id extents)
(when (eq (marker-buffer (car extents)) buffer)
(push region-id stale-ids)))
ebox--box-extents)
(dolist (region-id stale-ids)
(when-let ((extents (gethash region-id ebox--box-extents)))
(set-marker (car extents) nil)
(set-marker (cdr extents) nil))
(remhash region-id ebox--box-extents))))
(defun ebox--install-box-extent-template (buffer template start)
"Install prepared numeric box extent TEMPLATE in BUFFER at root START."
(when (and (buffer-live-p buffer) (hash-table-p template))
(with-current-buffer buffer
(setq-local
ebox--box-extent-template
(if (= start (point-min))
template
(let ((translated (make-hash-table :test 'equal))
(delta (- start (point-min))))
(maphash
(lambda (region-id span)
(puthash region-id
(cons (+ delta (car span)) (+ delta (cdr span)))
translated))
template)
translated))))))
(defun ebox--materialize-box-extent-template (buffer)
"Promote BUFFER's prepared extent template to live marker pairs."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when (hash-table-p ebox--box-extent-template)
(maphash
(lambda (region-id span)
(ebox--set-box-extents region-id (car span) (cdr span)))
ebox--box-extent-template)
(setq-local ebox--box-extent-template nil)
t))))
(defun ebox--clear-box-extents-for-region-ids (region-ids)
"Remove cached box extents for REGION-IDS."
(dolist (region-id region-ids)
(when-let ((extents (gethash region-id ebox--box-extents)))
(set-marker (car extents) nil)
(set-marker (cdr extents) nil))
(remhash region-id ebox--box-extents)))
(defun ebox--refresh-box-extents-for-region-ids-in-spans (region-ids spans)
"Refresh box extents for REGION-IDS inside SPANS."
(ebox--clear-box-extents-for-region-ids region-ids)
(when spans
(let ((start (apply #'min (mapcar #'car spans)))
(end (apply #'max (mapcar #'cdr spans)))
(region-id-allow-set (make-hash-table :test 'equal)))
(dolist (region-id region-ids)
(puthash region-id t region-id-allow-set))
(when (< start end)
(ebox--register-box-extents-in-range
start end region-id-allow-set)))))
(defun ebox--span-bounds (span)
"Return numeric bounds for SPAN."
(cons (if (markerp (car span))
(marker-position (car span))
(car span))
(if (markerp (cdr span))
(marker-position (cdr span))
(cdr span))))
(defun ebox--spans-bounding-range (spans)
"Return a numeric bounding range for SPANS."
(when spans
(let* ((first (ebox--span-bounds (car spans)))
(start (car first))
(end (cdr first)))
(dolist (span (cdr spans))
(pcase-let ((`(,span-start . ,span-end)
(ebox--span-bounds span)))
(setq start (min start span-start))
(setq end (max end span-end))))
(cons start end))))
(defun ebox--box-extents-contained-in-spans-p (region-id spans)
"Return non-nil when REGION-ID's live extents are inside SPANS."
(when-let* ((extents (gethash region-id ebox--box-extents))
((markerp (car extents)))
((markerp (cdr extents)))
(range (ebox--spans-bounding-range spans)))
(let ((start (marker-position (car extents)))
(end (marker-position (cdr extents))))
(and start end
(<= (car range) start)
(<= end (cdr range))))))
(defun ebox--box-extent-refresh-region-ids
(region-ids spans &optional active-segments)
"Return REGION-IDS whose cached extents can be refreshed in SPANS."
(let ((root-id (ebox--buffer-root-node-id (current-buffer))))
(cl-loop for region-id in region-ids
for active-region-p = (and active-segments
(gethash region-id active-segments))
for contained-p = (ebox--box-extents-contained-in-spans-p
region-id spans)
when (or contained-p
(and active-region-p
(not (equal region-id root-id))))
collect region-id)))
(defun ebox--refresh-box-extents-from-segments (segments-by-region)
"Refresh box extents from active SEGMENTS-BY-REGION."
(maphash
(lambda (region-id region-spans)
(when region-spans
(let* ((ordered
(sort region-spans
(lambda (left right)
(if (= (car left) (car right))
(< (cdr left) (cdr right))
(< (car left) (car right))))))
(range (copy-tree (pop ordered)))
(interleaved
(ebox--foreign-region-in-range-p
(car range) (cdr range) region-id)))
(dolist (segment ordered)
(unless interleaved
(cond
((ebox--foreign-region-in-range-p
(car segment) (cdr segment) region-id)
(setq interleaved t))
((<= (car segment) (cdr range))
(setcdr range (max (cdr range) (cdr segment))))
((ebox--foreign-region-in-range-p
(cdr range) (car segment) region-id)
(setq interleaved t))
(t
(setcdr range (cdr segment))))))
(unless interleaved
(ebox--set-box-extents region-id (car range) (cdr range))))))
segments-by-region))
(defun ebox--active-extent-segments-for-region-ids
(active-segments region-ids)
"Return ACTIVE-SEGMENTS filtered to REGION-IDS."
(let ((region-set (ebox--region-id-set region-ids))
(filtered (make-hash-table :test 'equal)))
(maphash
(lambda (region-id segments)
(when (gethash region-id region-set)
(puthash region-id segments filtered)))
active-segments)
filtered))
(defun ebox--refresh-box-extents-from-role-spans
(region-ids spans &optional active-segments)
"Refresh REGION-IDS extents in SPANS from the current role-span index.
The role index has already scanned the replacement text. Reusing it avoids a
second per-character pass over every Ebox property. Wrapper-only ownership is
merged from `ebox-content-owners'. Fall back to the ordinary scoped scanner
when no role index is available."
(if (not (ebox-buffer--region-role-span-table))
(ebox--refresh-box-extents-for-region-ids-in-spans region-ids spans)
(let* ((refresh-region-ids
(ebox--box-extent-refresh-region-ids
region-ids spans active-segments))
(region-set (ebox--region-id-set refresh-region-ids)))
(ebox--clear-box-extents-for-region-ids refresh-region-ids)
(when spans
(let* ((range (ebox--spans-bounding-range spans))
(start (car range))
(end (cdr range))
(segments-by-region (make-hash-table :test 'equal))
(table (ebox-buffer--region-role-span-table)))
(if active-segments
(ebox--refresh-box-extents-from-segments
(ebox--active-extent-segments-for-region-ids
active-segments refresh-region-ids))
(cl-labels
((record (region-id segment-start segment-end)
(when (and (gethash region-id region-set)
(< segment-start segment-end)
(< segment-start end)
(> segment-end start))
(puthash region-id
(cons (cons (max start segment-start)
(min end segment-end))
(gethash region-id segments-by-region))
segments-by-region))))
(dolist (region-id region-ids)
(dolist (entry ebox-region-types)
(dolist (span
(gethash (ebox-buffer--region-role-key
region-id (car entry))
table))
(let ((span-start
(if (markerp (car span))
(marker-position (car span))
(car span)))
(span-end
(if (markerp (cdr span))
(marker-position (cdr span))
(cdr span))))
(when (and span-start span-end)
(record region-id span-start span-end))))))
(let ((pos start))
(while (< pos end)
(let* ((owners (get-text-property
pos 'ebox-content-owners))
(next (or (next-single-property-change
pos 'ebox-content-owners nil end)
end)))
(dolist (region-id owners)
(record region-id pos next))
(setq pos (max next (1+ pos)))))))
(ebox--refresh-box-extents-from-segments
segments-by-region)))))))
(defun ebox--clear-region-runtime-caches (region-ids)
"Remove region-scoped runtime cache entries for REGION-IDS."
(ebox--smooth-scroll-clear-regions region-ids)
(dolist (region-id region-ids)
(remhash region-id ebox--region-box-table)
(ebox--scroll-clear-state region-id)))
(defun ebox--clear-buffer-runtime-state (&optional buffer)
"Remove all runtime caches owned by BUFFER.
Defaults to the current buffer."
(let ((buffer (or buffer (current-buffer))))
(ebox--cancel-buffer-runtime-prewarm buffer)
(when-let ((root (ebox--buffer-root-node buffer)))
(ebox--clear-region-runtime-caches
(ebox--node-all-region-ids root)))
(ebox--clear-buffer-extents buffer)
(ebox--clear-buffer-render-state buffer)))
(defun ebox--cleanup-current-buffer ()
"Remove Ebox runtime state owned by the current buffer."
(ebox--clear-buffer-runtime-state (current-buffer)))
(defun ebox--foreign-region-in-range-p (start end region-id)
"Return non-nil if START..END contains another ebox region."
(let ((pos start)
found)
(while (and (< pos end) (not found))
(let ((next (or (next-property-change pos nil end) end)))
(save-excursion
(goto-char pos)
(skip-chars-forward "\n" next)
(when (< (point) next)
(let ((owners (ebox--pos-region-ids (point)))
(owner-stack
(get-text-property (point) 'ebox-content-owners)))
(when (cl-some (lambda (owner)
(and (not (equal owner region-id))
(not (member region-id owner-stack))))
owners)
(setq found t)))))
(setq pos next)))
found))
(defun ebox--region-property-segments-in-range (start end)
"Return sorted ebox region property segments between START and END."
(let (segments)
(let ((pos start))
(while (< pos end)
(let* ((owners (get-text-property pos 'ebox-content-owners))
(next (or (next-single-property-change
pos 'ebox-content-owners nil end)
end)))
(dolist (region-id owners)
(push (list region-id pos next) segments))
(setq pos (max next (1+ pos))))))
(dolist (entry ebox-region-types)
(let ((prop (cdr entry))
(pos start))
(while (< pos end)
(let* ((region-id (get-text-property pos prop))
(next (or (next-single-property-change pos prop nil end)
end)))
(when region-id
(push (list region-id pos next) segments))
(setq pos (max next (1+ pos)))))))
(sort segments
(lambda (left right)
(if (= (nth 1 left) (nth 1 right))
(< (nth 2 left) (nth 2 right))
(< (nth 1 left) (nth 1 right)))))))
(defconst ebox--box-extents-segment-scan-min-chars 2048
"Minimum span size before box extent refresh uses segment scanning.")
(defun ebox--register-box-extents-by-position-scan
(start end &optional region-id-allow-set)
"Cache extents only for boxes that occupy one safe buffer slice.
Gaps containing only separators, such as newlines, are fine. Gaps
containing another ebox region mean the box is interleaved by a concat
layout and must not be replaced through a single `delete-region'.
When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set."
(let ((ranges (make-hash-table :test 'equal))
(interleaved (make-hash-table :test 'equal))
(pos start))
(while (< pos end)
(dolist (region-id (ebox--pos-region-ids pos))
(when (or (null region-id-allow-set)
(gethash region-id region-id-allow-set))
(let ((range (gethash region-id ranges)))
(if range
(progn
(when (and (> pos (cdr range))
(ebox--foreign-region-in-range-p
(cdr range) pos region-id))
(puthash region-id t interleaved))
(setcdr range (1+ pos)))
(puthash region-id (cons pos (1+ pos)) ranges)))))
(setq pos (1+ pos)))
(maphash
(lambda (region-id range)
(unless (gethash region-id interleaved)
(ebox--set-box-extents region-id (car range) (cdr range))))
ranges)))
(defun ebox--register-box-extents-by-segment-scan
(start end &optional region-id-allow-set)
"Cache extents in START..END by scanning region property segments.
When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set."
(let ((ranges (make-hash-table :test 'equal))
(interleaved (make-hash-table :test 'equal)))
(dolist (segment (ebox--region-property-segments-in-range start end))
(pcase-let ((`(,region-id ,segment-start ,segment-end) segment))
(when (or (null region-id-allow-set)
(gethash region-id region-id-allow-set))
(let ((range (gethash region-id ranges)))
(if range
(progn
(when (and (> segment-start (cdr range))
(ebox--foreign-region-in-range-p
(cdr range) segment-start region-id))
(puthash region-id t interleaved))
(setcdr range (max (cdr range) segment-end)))
(puthash region-id
(cons segment-start segment-end)
ranges))))))
(maphash
(lambda (region-id range)
(unless (gethash region-id interleaved)
(ebox--set-box-extents region-id (car range) (cdr range))))
ranges)))
(defun ebox--register-box-extents-in-range
(start end &optional region-id-allow-set)
"Cache live box extents for ebox regions between START and END.
When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set."
(if (< (- end start) ebox--box-extents-segment-scan-min-chars)
(ebox--register-box-extents-by-position-scan
start end region-id-allow-set)
(ebox--register-box-extents-by-segment-scan
start end region-id-allow-set)))
(defun ebox--ensure-live-box-extents (region-id)
"Compute and cache live extents for REGION-ID on demand."
(when-let ((buffer (and (fboundp 'ebox--region-buffer)
(ebox--region-buffer region-id))))
(with-current-buffer buffer
(if-let ((span (and (hash-table-p ebox--box-extent-template)
(gethash region-id ebox--box-extent-template))))
(ebox--set-box-extents region-id (car span) (cdr span))
(ebox--register-box-extents-in-range (point-min) (point-max)))
(gethash region-id ebox--box-extents))))
(defun ebox--live-box-extents (region-id)
"Return live extents for REGION-ID, or nil if unavailable."
(or (when-let ((extents (gethash region-id ebox--box-extents)))
(let* ((start-marker (car extents))
(end-marker (cdr extents))
(buffer (marker-buffer start-marker))
(start (marker-position start-marker))
(end (marker-position end-marker)))
(when (and buffer
(eq buffer (marker-buffer end-marker))
start end
(< start end))
extents)))
(ebox--ensure-live-box-extents region-id)))
(defun ebox--box-visible-overflow-p (box)
"Return non-nil when BOX renders unowned visible overflow lines."
(and (eq (ebox-get box :overflow) 'visible)
(let* ((formatted-content (ebox--format-content box))
(text-height (ebox-string-height formatted-content)))
(> text-height (ebox--content-height box text-height)))))
(defun ebox--plist-remove (plist key)
"Remove KEY and its value from PLIST, return new plist."
(let ((result nil))
(while plist
(unless (eq (car plist) key)
(push (car plist) result)
(push (cadr plist) result))
(setq plist (cddr plist)))
(nreverse result)))
(defconst ebox--region-update-longhand-props
'(:box-sizing
:width :min-width :max-width
:height :min-height :max-height
:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height
:border-left-pixel :border-left-color
:border-right-pixel :border-right-color
:border-top-p :border-top-color
:border-bottom-p :border-bottom-color
:color :bgcolor
:text-align :vertical-align
:overflow :wrap-mode
:scroll-offset :visibility)
"Longhand box properties accepted by `ebox-region-update'.")
(defun ebox--region-update-normalize-property (property)
"Return the box longhand field for region-update PROPERTY."
(pcase property
(:background-color :bgcolor)
(_ property)))
(defun ebox--region-update-analysis (box expanded)
"Return the effective change analysis for BOX and EXPANDED properties."
(let (content-seen
content-value
style-changed-p
unknown-keys)
(cl-loop for (key value) on expanded by #'cddr
for target-key = (ebox--region-update-normalize-property key)
do
(cond
((eq target-key :content)
(setq content-seen t
content-value value))
((memq target-key ebox--region-update-longhand-props)
(when (not (equal (ebox-get box target-key) value))
(setq style-changed-p t)))
(t
(push key unknown-keys))))
(list :changed-p
(or style-changed-p
(and content-seen
(not (equal (ebox-get box :content) content-value))))
:unknown-keys (nreverse unknown-keys))))
(defun ebox--region-update-buffer (region-id)
"Return the render buffer for REGION-ID, preferring a valid buffer hint."
(or (when (and ebox--region-update-buffer-hint
(buffer-live-p ebox--region-update-buffer-hint))
(let ((region-id-set
(ebox--buffer-region-id-set ebox--region-update-buffer-hint)))
(when (and region-id-set
(gethash region-id region-id-set))
ebox--region-update-buffer-hint)))
(ebox--region-buffer region-id)))
(defun ebox--mirror-region-update-box (region-box-table region-id box)
"Mirror REGION-ID's BOX into REGION-BOX-TABLE and the legacy side table."
(when region-box-table
(puthash region-id box region-box-table))
(puthash region-id box ebox--region-box-table))
(defun ebox--scroll-state-sync-after-update-p (report)
"Return non-nil when REPORT requires scroll cache synchronization."
(and report
(not (eq (plist-get report :strategy) 'no-op))
(not (plist-get report :scroll-state-patch))
(not (plist-get report :lazy-scroll-deferred-patch))))
(defun ebox--sync-scroll-offset-state-window (region-id changed-keys)
"Synchronize REGION-ID's own scroll state/window after CHANGED-KEYS.
Return non-nil only when the runtime offset, cached offset, and rendered window
are settled in the same update transaction."
(if (not (equal changed-keys '(:scroll-offset)))
t
(if-let ((state (ebox--scroll-get-state region-id)))
(when-let* ((box (plist-get state :box))
(content-lines (plist-get state :content-lines))
(content-height (plist-get state :content-height)))
(let* ((max-offset
(max 0 (- (length content-lines) content-height)))
(target (max 0 (min max-offset
(or (ebox-get box :scroll-offset) 0))))
(current (or (plist-get state :scroll-offset) 0)))
(ebox-put box :scroll-offset target)
(unless (ebox--scroll-offset-state-settled-p region-id target)
(ebox--scroll-region-by region-id (- target current)))
(ebox--scroll-offset-state-settled-p region-id target)))
t)))
(defun ebox--sync-scroll-state-after-batch-flush (buffer pending report)
"Synchronize final runtime nodes from successful batch PENDING entries."
(when (and buffer pending
(ebox--scroll-state-sync-after-update-p report))
(let ((sources (make-hash-table :test 'equal))
(final-report report)
source-order)
(dolist (entry pending)
(when-let* ((region-id (plist-get entry :region-id))
(node (ebox--buffer-region-render-owner-node
buffer region-id))
(node-id (ebox--ensure-node-id node)))
(if-let ((source (gethash node-id sources)))
(plist-put
source :changed-keys
(delete-dups
(append
(copy-sequence (plist-get source :changed-keys))
(copy-sequence (plist-get entry :changed-keys)))))
(puthash node-id
(list :region-id region-id
:node node
:changed-keys
(copy-sequence (plist-get entry :changed-keys)))
sources)
(push node-id source-order))))
(dolist (node-id (nreverse source-order))
(let ((source (gethash node-id sources)))
(unless (ebox--sync-scroll-offset-state-window
(plist-get source :region-id)
(plist-get source :changed-keys))
(error "Ebox could not atomically synchronize scroll state %S"
(plist-get source :region-id)))
(setq final-report
(ebox--sync-scroll-state-lines-for-node
buffer
(plist-get source :region-id)
(plist-get source :node)
(plist-get source :changed-keys)
final-report))))
(ebox--set-buffer-update-report buffer final-report))))
(add-hook 'ebox-incremental--after-successful-batch-flush-hook
#'ebox--sync-scroll-state-after-batch-flush)
(defun ebox--schedule-runtime-prewarm-after-batch-flush
(buffer _pending _report)
"Schedule shared runtime prewarming after BUFFER's batch flush."
(ebox--schedule-buffer-runtime-prewarm buffer))
(add-hook 'ebox-incremental--after-successful-batch-flush-hook
#'ebox--schedule-runtime-prewarm-after-batch-flush t)
(defun ebox--root-width-reflow-update-p (buffer node changed-keys)
"Return non-nil when NODE changes BUFFER's root width constraint."
(and buffer node
(equal (ebox--ensure-node-id node)
(ebox--buffer-root-node-id buffer))
(cl-some (lambda (key)
(memq key '(:width :min-width :max-width)))
changed-keys)))
;;;###autoload
(defun ebox-region-update (region-id &rest props)
"Update one or more properties of the box identified by REGION-ID.
PROPS accepts the same shorthand and longhand keywords as `ebox-create'.
Properties are expanded through `ebox-property-rules', written to the box
plist, then rendered once. Unsupported pseudo properties such as
:display are ignored by the shared rule table; :visibility affects rendering.
Return nil when the change is queued in an active explicit batch. Otherwise
return the report stored by `ebox-buffer-update-report'; legal same-value
updates return a `no-op' report.
Examples:
(ebox-region-update id :content \"Hello\")
(ebox-region-update id :padding (list 1 2) :border-color \"red\")
(ebox-region-update id :content \"Hi\" :width 30 :color \"white\")"
(ebox--with-render-gc
(ebox--with-validated-display-cache
(catch 'ebox-region-update-result
(let* ((inhibit-read-only t)
(buffer (ebox--region-update-buffer region-id))
(region-box-table
(and buffer (ebox--buffer-region-box-table buffer)))
(box
(if (hash-table-p region-box-table)
(or (gethash region-id region-box-table)
(error "No box found in buffer runtime for region %S"
region-id))
(or (gethash region-id ebox--region-box-table)
(error "No box found for region %S" region-id))))
(expanded (ebox--expand-plist props))
(analysis (ebox--region-update-analysis box expanded)))
(dolist (key (plist-get analysis :unknown-keys))
(message "ebox-region-update: unknown key %S (ignored)" key))
(unless (plist-get analysis :changed-p)
(if (and buffer (ebox-incremental-batching-p buffer))
(throw 'ebox-region-update-result nil)
(let ((report
(ebox--update-report
region-id 'no-op
:dirty-count 0
:patch-count 0
:patch-ops nil)))
(when buffer
(ebox--set-buffer-update-report buffer report))
(throw 'ebox-region-update-result report))))
(when (and buffer ebox--prepared-root-render
(not (ebox-incremental-batching-p buffer))
(= (length expanded) 2)
(eq (ebox--region-update-normalize-property (car expanded))
:width))
(let* ((state (ebox--buffer-render-state buffer))
(root (and state (plist-get state :root-node)))
(root-box
(pcase (plist-get root :ebox-type)
('box root)
('flex (plist-get root :box))))
(node (ebox--buffer-region-render-owner-node buffer region-id))
(target-box
(and root-box
(plist-put (copy-sequence root-box)
:width (cadr expanded))))
(target-width
(and target-box
(ebox--literal-root-pixel-width target-box)))
(report
(and (eq box root-box) target-width
(ebox--root-width-reflow-update-p
buffer node '(:width))
(with-current-buffer buffer
(ebox-incremental--publish-prepared-root-native
buffer
(list
:kind 'root-width :region-id region-id :box box
:model-node-id (ebox--ensure-node-id box)
:target-root-width target-width
:target-viewport-width
(plist-get state :viewport-width)
:target-viewport-height
(plist-get state :viewport-height)))))))
(when report
(ebox--mirror-region-update-box
region-box-table region-id box)
(ebox--schedule-buffer-runtime-prewarm buffer)
(throw 'ebox-region-update-result report))))
(let* ((state (and buffer (ebox--buffer-render-state buffer)))
(node (and buffer
(ebox--buffer-region-render-owner-node
buffer region-id)))
(affected-scroll-ids
(let ((subtree-ids
(and node (ebox--node-all-region-ids node)))
(containing-ids
(and buffer node
(ebox--scroll-state-region-ids-containing-node-ids
buffer (list (ebox--ensure-node-id node))))))
(delete-dups
(delq nil (append (list region-id)
subtree-ids
containing-ids)))))
(box-before (copy-tree box))
(state-revision-before
(and state (plist-get state :runtime-revision)))
(state-reflow-scratch-before
(and state (plist-get state :reflow-prewarm-scratch)))
(state-report-before
(and state (plist-get state :last-update-report)))
(scroll-before
(mapcar
(lambda (scroll-id)
(let ((scroll-state (ebox--scroll-get-state scroll-id)))
(list
scroll-id scroll-state
(cl-loop for (key value) on scroll-state by #'cddr
append
(list
key
(if (or
(eq key :box)
(memq
key
ebox--scroll-cache-transient-state-keys))
value
(copy-tree value)))))))
affected-scroll-ids))
(transaction-completed nil)
(prewarm-cancelled nil)
(mutation-notified-p nil)
changed-keys
old-style-values
result-report
(content-seen nil)
content-val
content-changed)
(ebox--mirror-region-update-box region-box-table region-id box)
(cl-labels
((restore-plist-object
(object snapshot)
(when object
(setcar object (car snapshot))
(setcdr object (cdr snapshot))))
(restore-model ()
(let ((inhibit-quit t))
(restore-plist-object box box-before)
(ebox--mirror-region-update-box
region-box-table region-id box)
(dolist (entry scroll-before)
(pcase-let ((`(,scroll-id ,scroll-state ,snapshot) entry))
(if scroll-state
(progn
(restore-plist-object scroll-state snapshot)
(puthash scroll-id scroll-state
ebox--scroll-global-state))
(remhash scroll-id ebox--scroll-global-state))))
(when state
(plist-put state :runtime-revision state-revision-before)
(plist-put state :reflow-prewarm-scratch
state-reflow-scratch-before)
(plist-put state :last-update-report state-report-before))
(when (buffer-live-p buffer)
(ebox--refresh-buffer-box-extents buffer)
(ebox-buffer-refresh-region-role-spans buffer)
(ebox--refresh-buffer-scroll-content-markers buffer))))
(run-update ()
(cl-loop for (k v) on expanded by #'cddr do
(let ((target-key
(ebox--region-update-normalize-property k)))
(cond
((eq target-key :content)
(setq content-seen t
content-val v))
((memq target-key ebox--region-update-longhand-props)
(unless (equal (ebox-get box target-key) v)
(when (and (eq target-key :overflow)
(not (plist-member old-style-values
target-key)))
(setq old-style-values
(plist-put old-style-values target-key
(ebox-get box target-key))))
(when (and buffer (not prewarm-cancelled))
(ebox--cancel-buffer-runtime-prewarm buffer)
(setq prewarm-cancelled t))
(unless mutation-notified-p
(when buffer
(ebox-incremental--notify-before-runtime-mutation
buffer 'region-update))
(setq mutation-notified-p t))
(ebox-put box target-key v)
(push target-key changed-keys)))
(t nil))))
(setq content-changed
(and content-seen
(not (equal (ebox-get box :content) content-val))))
(when (and (null changed-keys)
(not content-changed)
(not (and buffer
(ebox-incremental-batching-p buffer))))
(setq result-report
(ebox--update-report
region-id 'no-op
:dirty-count 0
:patch-count 0
:patch-ops nil))
(when buffer
(ebox--set-buffer-update-report buffer result-report)))
(when (and buffer (or changed-keys content-changed))
(unless prewarm-cancelled
(ebox--cancel-buffer-runtime-prewarm buffer)
(setq prewarm-cancelled t))
(ebox--invalidate-buffer-render-signatures
buffer (ebox--ensure-node-id box)))
;; content sync (scroll state + rerender)
(when content-changed
(unless mutation-notified-p
(when buffer
(ebox-incremental--notify-before-runtime-mutation
buffer 'region-update))
(setq mutation-notified-p t))
(let ((scroll-state (ebox--scroll-get-state region-id)))
(ebox-put box :content content-val)
(push :content changed-keys)
(when scroll-state
(let* ((formatted (ebox--format-content box))
(formatted-lines (ebox-string-lines formatted))
(text-height (length formatted-lines))
(content-height (ebox--content-height box text-height))
(requested-offset
(if (plist-member expanded :scroll-offset)
(or (ebox-get box :scroll-offset) 0)
(or (plist-get scroll-state :scroll-offset) 0)))
(scroll-offset
(max 0
(min requested-offset
(max 0 (- text-height content-height))))))
(ebox-put box :scroll-offset scroll-offset)
(setq scroll-state
(ebox--scroll-state-set-lines
scroll-state formatted-lines))
(plist-put scroll-state :content-height content-height)
(plist-put scroll-state :scroll-offset scroll-offset)
(plist-put scroll-state :box box)))))
;; Merge non-content changes into one dirty kind, then let the
;; shared constraint-change executor choose the smallest valid
;; patch owner.
(when changed-keys
(setq changed-keys (nreverse changed-keys))
(let* ((dirty-kind (ebox--region-update-dirty-kind changed-keys))
(batched
(and buffer
(ebox-incremental-record-region-change
buffer region-id dirty-kind changed-keys
old-style-values)))
root-width-reflow)
(when batched
(ebox--cancel-buffer-runtime-prewarm buffer))
(unless batched
(setq result-report
(let* ((_root-width-reflow
(setq root-width-reflow
(ebox--root-width-reflow-update-p
buffer node changed-keys))))
(when (and node (not root-width-reflow))
(ebox--ensure-visible-node-runtime-indexes
buffer node region-id))
(or (ebox--try-scroll-offset-fast-path
buffer region-id changed-keys)
(and node (not root-width-reflow)
(eq dirty-kind 'geometry)
(ebox--try-lazy-scroll-deferred-patch
buffer region-id node changed-keys))
(and node (not root-width-reflow)
(eq dirty-kind 'geometry)
(ebox--try-hidden-scroll-state-span-patch
buffer region-id node changed-keys))
(and node (not root-width-reflow)
(eq dirty-kind 'geometry)
(ebox--try-hidden-scroll-root-fallback
buffer region-id node changed-keys))
(and node (not root-width-reflow)
(eq dirty-kind 'geometry)
(ebox--try-scroll-state-span-patch
buffer region-id node changed-keys))
(ebox--rerender-region-for-dirty-kind
region-id dirty-kind changed-keys buffer
old-style-values))))
(when buffer
(let* ((confirm-reflow-scratch
(and root-width-reflow
(equal changed-keys '(:width))))
(revision
(ebox--bump-buffer-runtime-revision
buffer confirm-reflow-scratch)))
(when confirm-reflow-scratch
(ebox--confirm-reflow-prewarm-scratch
buffer region-id revision))))
(when (and buffer
(not root-width-reflow)
(ebox--scroll-state-sync-after-update-p
result-report))
(unless (ebox--sync-scroll-offset-state-window
region-id changed-keys)
(error
"Ebox could not atomically synchronize scroll state %S"
region-id))
(setq result-report
(ebox--sync-scroll-state-lines-for-node
buffer region-id node changed-keys result-report)))
(when (and buffer result-report)
(ebox--set-buffer-update-report buffer result-report))
(when buffer
(ebox--schedule-buffer-runtime-prewarm buffer)))))))
(unwind-protect
(progn
(if (buffer-live-p buffer)
(with-current-buffer buffer
(atomic-change-group
(run-update)))
(run-update))
(setq transaction-completed t))
(unless transaction-completed
(restore-model)))
result-report)))))))
(defvar ebox-scroll-map nil
"Keymap for scroll interaction within ebox.")
(setq ebox-scroll-map
(let ((map (make-sparse-keymap)))
(dolist (area '(nil right-fringe left-fringe right-margin left-margin
vertical-scroll-bar horizontal-scroll-bar
mode-line header-line tool-bar))
(let ((prefix (if area (vector area) [])))
(dolist (event '(wheel-down double-wheel-down triple-wheel-down
mouse-5))
(define-key map (vconcat prefix (vector event))
#'ebox-wheel-scroll-down))
(dolist (event '(wheel-up double-wheel-up triple-wheel-up
mouse-4))
(define-key map (vconcat prefix (vector event))
#'ebox-wheel-scroll-up))))
(define-key map [remap scroll-up-command] #'ebox-scroll-page-down)
(define-key map [remap scroll-down-command] #'ebox-scroll-page-up)
(define-key map (kbd "n") #'ebox-scroll-down)
(define-key map (kbd "p") #'ebox-scroll-up)
(dolist (key '("C-v" "<next>" "<kp-next>" "SPC"))
(define-key map (kbd key) #'ebox-scroll-page-down))
(dolist (key '("M-v" "<prior>" "<kp-prior>" "S-SPC" "<backspace>"))
(define-key map (kbd key) #'ebox-scroll-page-up))
(define-key map (kbd "q") #'quit-window)
map))
(defconst ebox--scroll-overridden-minor-modes
'(pixel-scroll-precision-mode pixel-scroll-mode)
"Minor modes whose scroll bindings should not override ebox buffers.")
(defun ebox--install-scroll-map-overrides ()
"Make ebox scroll bindings win over global pixel-scroll bindings locally."
(setq-local minor-mode-overriding-map-alist
(cl-remove-if
(lambda (entry)
(memq (car entry) ebox--scroll-overridden-minor-modes))
minor-mode-overriding-map-alist))
(dolist (mode ebox--scroll-overridden-minor-modes)
(push (cons mode ebox-scroll-map)
minor-mode-overriding-map-alist)))
(defun ebox--clear-scroll-map-overrides ()
"Remove ebox-local scroll binding overrides from the current buffer."
(setq-local minor-mode-overriding-map-alist
(cl-remove-if
(lambda (entry)
(memq (car entry) ebox--scroll-overridden-minor-modes))
minor-mode-overriding-map-alist)))
;;;###autoload
(define-minor-mode ebox-buffer-mode
"Minor mode for interactive ebox buffers."
:lighter " Ebox"
:keymap ebox-scroll-map
(if ebox-buffer-mode
(ebox--install-scroll-map-overrides)
(ebox--clear-scroll-map-overrides)))
;;;###autoload
(defun ebox-render-to-buffer (buffer-or-name node)
"Render layout NODE into BUFFER-OR-NAME and return the buffer.
NODE can be any box/concat/stack node created by `ebox-create',
`ebox-concat', or `ebox-stack', or a pre-rendered string."
(declare (indent 1))
(unless (stringp node)
;; Validate opaque host identities before clearing an existing target.
(ebox-tree-validate-host-refs node))
(let* ((runtime-root
(if (stringp node)
node
(ebox--prepare-buffer-runtime-root
node buffer-or-name)))
(buffer (get-buffer-create buffer-or-name)))
(ebox--with-render-gc
(with-current-buffer buffer
(let ((inhibit-read-only t))
(ebox--clear-buffer-runtime-state buffer)
(erase-buffer)
(progn
(if (stringp runtime-root)
(insert runtime-root)
(ebox--set-buffer-render-state buffer runtime-root)
(condition-case err
(insert
(ebox--with-buffer-render-context buffer
(ebox-render runtime-root)))
(error
(ebox--clear-buffer-render-state buffer)
(signal (car err) (cdr err)))))
(unless (stringp runtime-root)
(ebox--refresh-buffer-scroll-content-markers buffer)
(ebox-buffer-refresh-region-role-spans buffer)))
(goto-char (point-min)))
(add-hook 'kill-buffer-hook #'ebox--cleanup-current-buffer nil t)
(ebox-buffer-mode 1)
(read-only-mode 1)
(ebox--schedule-buffer-runtime-prewarm buffer)
buffer))))
(defun ebox--host-ref-buffer (buffer-or-name)
"Return the live buffer named by BUFFER-OR-NAME, or nil."
(let ((buffer (and buffer-or-name (get-buffer buffer-or-name))))
(and (buffer-live-p buffer) buffer)))
(defun ebox--host-ref-node (buffer host-ref)
"Return BUFFER's runtime node indexed by opaque HOST-REF."
(when-let* ((table (ebox--buffer-host-ref-table buffer))
(node-id (gethash host-ref table)))
(ebox--buffer-runtime-node buffer node-id)))
(defun ebox--host-ref-role-bounds (buffer node)
"Return NODE's margin-free role bounds in BUFFER, or nil."
(with-current-buffer buffer
(save-restriction
(widen)
(let (start end)
(dolist (region-id (ebox--node-all-region-ids node))
(dolist (role ebox--horizontal-border-anchor-roles)
(dolist (span (ebox--region-find region-id role))
(let ((span-start (if (markerp (car span))
(marker-position (car span))
(car span)))
(span-end (if (markerp (cdr span))
(marker-position (cdr span))
(cdr span)))
(start-buffer (and (markerp (car span))
(marker-buffer (car span))))
(end-buffer (and (markerp (cdr span))
(marker-buffer (cdr span)))))
(when (and span-start span-end (< span-start span-end)
(or (and (null start-buffer) (null end-buffer))
(and (eq start-buffer buffer)
(eq end-buffer buffer))))
(setq start (if start (min start span-start) span-start)
end (if end (max end span-end) span-end)))))))
(and start end (cons start end))))))
(defun ebox--host-ref-extent-bounds (buffer node)
"Return fallback live extent bounds for NODE in BUFFER, or nil."
(let (start end)
(dolist (region-id (ebox--node-all-region-ids node))
(when-let* ((extents (ebox--live-box-extents region-id))
(start-marker (car extents))
(end-marker (cdr extents))
(region-start (marker-position start-marker))
(region-end (marker-position end-marker)))
(when (and (eq (marker-buffer start-marker) buffer)
(eq (marker-buffer end-marker) buffer))
(setq start (if start (min start region-start) region-start)
end (if end (max end region-end) region-end)))))
(and start end (< start end) (cons start end))))
;;;###autoload
(defun ebox-host-ref-bounds (buffer-or-name host-ref)
"Return live integer bounds for HOST-REF in BUFFER-OR-NAME.
The result is a `(START . END)' pair spanning the referenced runtime node's
rendered border-box characters (excluding margins), or nil when the buffer,
reference, or live box extents no longer exist. HOST-REF is opaque and
compared with `equal'."
(when-let* ((buffer (ebox--host-ref-buffer buffer-or-name))
(node (ebox--host-ref-node buffer host-ref)))
(or (ebox--host-ref-role-bounds buffer node)
(ebox--host-ref-extent-bounds buffer node))))
;;;###autoload
(defun ebox-host-ref-position (buffer-or-name host-ref)
"Return the first live buffer position for HOST-REF, or nil.
See `ebox-host-ref-bounds' for lookup and lifetime semantics."
(car-safe (ebox-host-ref-bounds buffer-or-name host-ref)))
;;;###autoload
(defun ebox-candidate-begin (buffer-or-name)
"Begin a one-shot logical transaction from BUFFER-OR-NAME's exact runtime."
(let ((buffer (get-buffer buffer-or-name)))
(unless (buffer-live-p buffer)
(error "Ebox candidate requires an existing live buffer: %S"
buffer-or-name))
(ebox-incremental-candidate-begin buffer)))
;;;###autoload
(defun ebox-candidate-replace
(candidate node-id next-subtree
&optional old-semantic-key new-semantic-key)
"Replace NODE-ID in CANDIDATE with declarative NEXT-SUBTREE.
OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY may identify a stable anchor's detached
semantic variants so a later return can reuse bounded runtime identity.
Return CANDIDATE for convenient transaction construction."
(ebox-incremental-candidate-replace
candidate node-id next-subtree old-semantic-key new-semantic-key))
;;;###autoload
(defun ebox-candidate-replace-host-ref
(candidate host-ref next-subtree
&optional old-semantic-key new-semantic-key)
"Replace HOST-REF in CANDIDATE with declarative NEXT-SUBTREE.
HOST-REF is resolved against the exact published runtime captured by
`ebox-candidate-begin'. OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY have the same
optional detached-identity meaning as in `ebox-candidate-replace'.
Return CANDIDATE."
(ebox-incremental-candidate-replace-host-ref
candidate host-ref next-subtree old-semantic-key new-semantic-key))
;;;###autoload
(defun ebox-commit (buffer-or-name next-root &optional after-publication)
"Atomically commit declarative NEXT-ROOT into BUFFER-OR-NAME.
NEXT-ROOT may be a newly built Ebox node tree or a one-shot logical candidate
from `ebox-candidate-begin'. A complete root is copied into a buffer-owned
runtime. A logical candidate path-copies replaced ancestor chains, shares
untouched subtrees, and isolates retained scroll nodes before proof rendering.
Both forms reconcile Ebox-owned identity,
prove rendered owners in isolation, and publish only property-aware buffer
differences. Validation, render, and publication failures leave the current
buffer and runtime unchanged.
When AFTER-PUBLICATION is non-nil, it must be a function of one update report.
Ebox invokes it inside the quit-free atomic publication boundary after the
buffer and runtime agree. This hook is for framework pointer promotion only;
it must not run application code. An error rolls the Ebox publication back.
Return the successful publication report stored by `ebox-buffer-update-report'."
(unless (or (null after-publication) (functionp after-publication))
(signal 'wrong-type-argument (list 'functionp after-publication)))
(let ((ebox-incremental--after-declarative-publication
(or after-publication
ebox-incremental--after-declarative-publication)))
(if (ebox-candidate-p next-root)
(ebox-incremental-commit-candidate buffer-or-name next-root)
(let ((buffer (get-buffer buffer-or-name)))
(unless (buffer-live-p buffer)
(error "Ebox declarative commit requires an existing live buffer: %S"
buffer-or-name))
(ebox-incremental-commit buffer next-root)))))
;;;###autoload
(defun ebox-rerender-buffer-with-context
(buffer viewport-width &optional viewport-height)
"Rerender BUFFER's stored runtime tree using VIEWPORT-WIDTH.
When VIEWPORT-HEIGHT is non-nil, height values using `(viewport-height)' are
resolved against that line count.
This preserves node and region identity and routes viewport-dependent changes
through dirty-set and patch-set execution before falling back to root rerender."
(let* ((state (ebox--buffer-render-state buffer))
(old-viewport-width (and state
(plist-get state :viewport-width))))
(ebox--cancel-buffer-runtime-prewarm buffer)
(prog1
(ebox-incremental-rerender-buffer-with-context
buffer viewport-width viewport-height)
(ebox--schedule-buffer-runtime-prewarm buffer)
(ebox--schedule-buffer-reflow-cache-prewarm
buffer old-viewport-width viewport-width))))
;;;###autoload
(defun ebox-display-buffer (buffer-or-name node)
"Render NODE to BUFFER-OR-NAME and display the buffer with `pop-to-buffer'."
(declare (indent 1))
(delete-other-windows)
(switch-to-buffer (ebox-render-to-buffer buffer-or-name node)))
(defconst ebox-public-api
'(ebox-buffer-mode
ebox-buffer-update-report
ebox-build
ebox-byte-compile
ebox-candidate-begin
ebox-candidate-replace
ebox-candidate-replace-host-ref
ebox-clear-cache
ebox-column
ebox-commit
ebox-concat
ebox-create
ebox-display-buffer
ebox-flex
ebox-flex-item
ebox-host-ref-bounds
ebox-host-ref-position
ebox-pop-to-buffer
ebox-region-ids
ebox-region-update
ebox-render
ebox-render-to-buffer
ebox-rerender-buffer-with-context
ebox-row
ebox-scroll-down
ebox-scroll-map
ebox-scroll-page-down
ebox-scroll-page-up
ebox-scroll-state
ebox-scroll-up
ebox-select-all
ebox-selector-match-node-p
ebox-selector-parse
ebox-selector-query-all
ebox-selector-query-buffer
ebox-selector-update-buffer
ebox-spacer
ebox-stack
ebox-switch-to-buffer
ebox-update-selector
ebox-wheel-scroll-down
ebox-wheel-scroll-up)
"Stable core Ebox entry points available to applications and tooling.")
(defconst ebox-feature-families
'(box-model
declarative-commit
direct-region-update
dsl
failure-atomic-rollback
flex-layout
host-reference
intrinsic-and-viewport-sizing
logical-candidate
native-reflow
overflow-and-wrapping
paint-and-text-properties
prepared-publication
render-cache
scroll-window
selector-runtime-index
stable-runtime-identity
surface-properties
viewport-reflow)
"Stable high-level Ebox capability families used by conformance tooling.")
(provide 'ebox)
;;; ============================================================
;;; Public API Summary
;;; ============================================================
;; The following functions form the stable public interface of ebox.
;; All other ebox-- prefixed functions are internal implementation
;; details and may change without notice.
;;
;; ── Creation ──────────────────────────────────────────────────
;; ebox-create &rest plist
;; Create a box. Key options: :content :width :height
;; :padding :margin :border :border-color :bgcolor
;; :text-align :wrap-mode :box-sizing :surface-properties :host-ref
;; ebox-build dsl
;; Compile ETML-style (box/row/column/flex/item/spacer ...) forms into
;; normal ebox layout nodes.
;;
;; ── Layout ────────────────────────────────────────────────────
;; ebox-concat left right → horizontal side-by-side
;; ebox-stack top bottom → vertical stacking (nest for 3+)
;; ebox-row &rest nodes → horizontal layout from many nodes
;; ebox-column &rest nodes → vertical layout from many nodes
;; ebox-spacer &rest plist → blank box node
;; ebox-flex &rest items → flex layout from boxes/items
;; Container may also carry normal ebox-create box properties.
;; ebox-flex-item node &rest plist
;; Attach :order :flex-grow :flex-shrink :flex-basis :flex
;; and :align-self metadata to a child node; non-flex properties
;; wrap the child in an ebox-create box.
;;
;; ── Rendering ─────────────────────────────────────────────────
;; ebox-render box → propertized string (no buffer side-effect)
;; ebox-render-to-buffer buf box → insert into buffer, returns buffer
;; ebox-commit buf next-root → atomically publish a declarative root
;; ebox-candidate-begin buf → begin a one-shot logical transaction
;; ebox-candidate-replace cand id root &optional old-key new-key
;; → replace one runtime node logically
;; ebox-candidate-replace-host-ref cand ref root &optional old-key new-key
;; → replace one semantic host boundary
;; ebox-display-buffer buf box → render and display with pop-to-buffer
;; ebox-host-ref-position buf ref → first live position for :host-ref
;; ebox-host-ref-bounds buf ref → live (START . END) bounds for :host-ref
;;
;; ── Region IDs ────────────────────────────────────────────────
;; ebox-region-ids layout → list of region-ids in document order
;; call BEFORE render-to-buffer
;;
;; ── Dynamic Updates (call inside with-current-buffer + inhibit-read-only) ──
;; ebox-region-update region-id &rest props
;; Single entry-point for all dynamic changes. PROPS keywords mirror
;; those of ebox-create:
;; :content STRING
;; :width SIZE :padding-left SIZE :padding-right SIZE …
;; :border-color COLOR :border-top-color COLOR …
;; :bgcolor COLOR
;; Multiple keywords may be combined in one call.
;;
;; ── Scroll ────────────────────────────────────────────────────
;; ebox-scroll-up &optional n
;; ebox-scroll-down &optional n
;; ebox-scroll-map (keymap, bind to buffer local map)
;;
;; ── Cache ─────────────────────────────────────────────────────
;; ebox-clear-cache
;;; ebox.el ends here