400 lines
16 KiB
EmacsLisp
400 lines
16 KiB
EmacsLisp
;;; ebox-measure.el --- Pixel measurement helpers for Ebox -*- lexical-binding: t; -*-
|
|
|
|
;;; Commentary:
|
|
;; Owns display-signature-sensitive measurement caches and text/space width
|
|
;; helpers. It does not own layout decisions or dirty classification.
|
|
|
|
;;; Code:
|
|
|
|
(require 'cl-lib)
|
|
(require 'ebox-cache)
|
|
|
|
(declare-function ebox--register-render-owned-text-value
|
|
"ebox-render-context" (property value))
|
|
(declare-function ebox-string-lines "ebox" (string))
|
|
(declare-function ebox--string-repeat-lines "ebox" (string count))
|
|
|
|
;; Measurement Cache Model:
|
|
;; These caches only accelerate display-signature-sensitive pixel measurement.
|
|
;; They must not carry layout identity, dirty state, or buffer ownership.
|
|
|
|
(defvar ebox--char-width-cache (make-hash-table :test 'eq)
|
|
"Cache for character pixel widths.")
|
|
|
|
(defvar ebox--space-pixel-cache nil
|
|
"Cache for space pixel width.")
|
|
|
|
(defvar ebox--face-height-width-cache nil
|
|
"Cache indicating whether explicit face height affects measured width.")
|
|
|
|
(defvar ebox--display-signature-cache nil
|
|
"Cached display signature used to invalidate pixel measurements.")
|
|
|
|
(defvar ebox--display-cache-validated nil
|
|
"Non-nil while the display signature has already been validated.
|
|
Bound to t for the dynamic extent of a single render so that the thousands
|
|
of pixel measurements it triggers do not each rebuild the display signature.")
|
|
|
|
(defvar ebox--render-display-signature nil
|
|
"Display signature snapshot for the current render transaction.")
|
|
|
|
(defvar ebox--render-string-pixel-width-cache nil
|
|
"Dynamic render-local cache for reusable string pixel widths.")
|
|
|
|
(defconst ebox--string-pixel-width-cache-max-entries 4096
|
|
"Maximum safe string measurements retained for one display signature.")
|
|
|
|
(defvar ebox--string-pixel-width-cache (make-hash-table :test 'equal)
|
|
"Display-signature-scoped cache for reusable string pixel widths.")
|
|
|
|
(defvar ebox--string-pixel-width-cache-ring
|
|
(make-vector ebox--string-pixel-width-cache-max-entries nil)
|
|
"Insertion-order keys for bounded string measurement eviction.")
|
|
|
|
(defvar ebox--string-pixel-width-cache-ring-index 0
|
|
"Next insertion slot in `ebox--string-pixel-width-cache-ring'.")
|
|
|
|
(defvar ebox--render-string-max-pixel-width-cache nil
|
|
"Dynamic render-local cache for rendered multiline string max widths.")
|
|
|
|
(defvar ebox--render-recached-source-node-cache nil
|
|
"Dynamic render-local set of source subtrees recached into region tables.")
|
|
|
|
(defun ebox--text-scale-factor ()
|
|
"Return the active text scale multiplier."
|
|
(let ((amount (and (boundp 'text-scale-mode-amount)
|
|
(numberp text-scale-mode-amount)
|
|
text-scale-mode-amount))
|
|
(step (if (and (boundp 'text-scale-mode-step)
|
|
(numberp text-scale-mode-step))
|
|
text-scale-mode-step
|
|
1.2)))
|
|
(if amount (expt step amount) 1.0)))
|
|
|
|
(defun ebox--display-space-width (display)
|
|
"Return fixed pixel width for DISPLAY space specs, or nil."
|
|
(when (and (consp display) (eq (car display) 'space))
|
|
(let ((width (plist-get (cdr display) :width)))
|
|
(cond
|
|
((numberp width) width)
|
|
((and (consp width) (numberp (car width))) (car width))))))
|
|
|
|
(defun ebox--string-with-text-scale (string factor)
|
|
"Return STRING with FACTOR applied as an explicit face height."
|
|
(if (= factor 1.0)
|
|
string
|
|
(let ((copy (copy-sequence string)))
|
|
(add-face-text-property 0 (length copy) `(:height ,factor) t copy)
|
|
copy)))
|
|
|
|
(defun ebox--face-height-affects-width-p (factor)
|
|
"Return non-nil when `string-pixel-width' honors explicit face FACTOR."
|
|
(or (= factor 1.0)
|
|
(let ((cached (assoc factor ebox--face-height-width-cache)))
|
|
(if cached
|
|
(cdr cached)
|
|
(let* ((probe "MMMMmmmm")
|
|
(plain (string-pixel-width probe))
|
|
(scaled (string-pixel-width
|
|
(ebox--string-with-text-scale probe factor)))
|
|
(works (/= scaled plain)))
|
|
(push (cons factor works) ebox--face-height-width-cache)
|
|
works)))))
|
|
|
|
(defun ebox--scaled-string-pixel-width (string factor)
|
|
"Return pixel width of STRING under text-scale FACTOR."
|
|
(let ((plain (string-pixel-width string)))
|
|
(cond
|
|
((= factor 1.0) plain)
|
|
((ebox--face-height-affects-width-p factor)
|
|
(string-pixel-width (ebox--string-with-text-scale string factor)))
|
|
(t
|
|
(ceiling (* plain factor))))))
|
|
|
|
(defun ebox--clear-string-pixel-width-cache ()
|
|
"Clear display-scoped reusable string measurements and eviction state."
|
|
(clrhash ebox--string-pixel-width-cache)
|
|
(fillarray ebox--string-pixel-width-cache-ring nil)
|
|
(setq ebox--string-pixel-width-cache-ring-index 0))
|
|
|
|
(defun ebox--string-pixel-width-visual-key
|
|
(line factor &optional start end)
|
|
"Return a width cache key for LINE slice at FACTOR.
|
|
START and END default to the complete string; visual properties must be
|
|
constant across the selected slice."
|
|
(let ((start (or start 0))
|
|
(end (or end (length line))))
|
|
(list factor
|
|
(substring-no-properties line start end)
|
|
(cl-loop for property in '(face display)
|
|
for value = (and (> end start)
|
|
(get-text-property start property line))
|
|
when value append (list property value)))))
|
|
|
|
(defun ebox--string-pixel-width-cache-key (line factor)
|
|
"Return a safe display-scoped measurement key for LINE at FACTOR."
|
|
(let* ((end (length line))
|
|
;; Ebox layout text uses `face' and `display' as its width-bearing
|
|
;; properties. Ownership/region metadata is deliberately ignored;
|
|
;; the other generic text properties are not emitted by Ebox's
|
|
;; layout pipeline and would make this hot key scan needlessly broad.
|
|
(constant-p
|
|
(and (= (or (next-single-property-change 0 'face line end) end)
|
|
end)
|
|
(= (or (next-single-property-change 0 'display line end) end)
|
|
end))))
|
|
(and constant-p
|
|
(ebox--string-pixel-width-visual-key line factor))))
|
|
|
|
(defun ebox--cached-string-pixel-width
|
|
(line factor key &optional start end)
|
|
"Return LINE slice width at FACTOR through both caches at KEY."
|
|
(let ((cached
|
|
(or (and ebox--render-string-pixel-width-cache
|
|
(gethash key ebox--render-string-pixel-width-cache))
|
|
(gethash key ebox--string-pixel-width-cache))))
|
|
(if cached
|
|
(progn
|
|
(when ebox--render-string-pixel-width-cache
|
|
(puthash key cached ebox--render-string-pixel-width-cache))
|
|
cached)
|
|
(let* ((sample (if (or start end)
|
|
(substring line (or start 0) (or end (length line)))
|
|
line))
|
|
(width (ebox--scaled-string-pixel-width sample factor))
|
|
(index ebox--string-pixel-width-cache-ring-index)
|
|
(evicted (aref ebox--string-pixel-width-cache-ring index)))
|
|
(when ebox--render-string-pixel-width-cache
|
|
(puthash key width ebox--render-string-pixel-width-cache))
|
|
(when evicted
|
|
(remhash evicted ebox--string-pixel-width-cache))
|
|
(puthash key width ebox--string-pixel-width-cache)
|
|
(aset ebox--string-pixel-width-cache-ring index key)
|
|
(setq ebox--string-pixel-width-cache-ring-index
|
|
(mod (1+ index)
|
|
ebox--string-pixel-width-cache-max-entries))
|
|
width))))
|
|
|
|
(defun ebox--string-pixel-width (string)
|
|
"Return the display pixel width of STRING's first line.
|
|
Text characters follow the current buffer text scale. Fixed display spaces
|
|
keep their absolute pixel width."
|
|
(ebox--ensure-display-cache-current)
|
|
(let* ((source (or string ""))
|
|
(newline (and (stringp source) (string-match "\n" source)))
|
|
(line (if newline (substring source 0 newline) source))
|
|
(factor (ebox--text-scale-factor))
|
|
(pos 0)
|
|
(end (length line))
|
|
(total 0)
|
|
(cache-key (ebox--string-pixel-width-cache-key line factor)))
|
|
(if cache-key
|
|
(ebox--cached-string-pixel-width line factor cache-key)
|
|
(while (< pos end)
|
|
(let* ((display (get-text-property pos 'display line))
|
|
(next
|
|
(cl-loop for property in
|
|
'(face display)
|
|
minimize (or (next-single-property-change
|
|
pos property line end)
|
|
end)))
|
|
(space-width (ebox--display-space-width display)))
|
|
(setq total
|
|
(+ total
|
|
(if space-width
|
|
(* space-width (- next pos))
|
|
;; The segment ends at every visual property change.
|
|
;; Build only its property-free cache key on hits; a
|
|
;; propertized substring is materialized solely on miss.
|
|
(let ((segment-key
|
|
(ebox--string-pixel-width-visual-key
|
|
line factor pos next)))
|
|
(ebox--cached-string-pixel-width
|
|
line factor segment-key pos next)))))
|
|
(setq pos next)))
|
|
total)))
|
|
|
|
(defun ebox--string-max-pixel-width (string)
|
|
"Return the maximum display pixel width across STRING's lines."
|
|
(if (string-empty-p string)
|
|
0
|
|
(let* ((cache ebox--render-string-max-pixel-width-cache)
|
|
(missing (make-symbol "ebox-string-max-pixel-width-missing"))
|
|
(cached (if cache
|
|
(gethash string cache missing)
|
|
missing)))
|
|
(if (not (eq cached missing))
|
|
cached
|
|
(let ((width (apply #'max
|
|
(mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines string)))))
|
|
(when cache
|
|
(puthash string width cache))
|
|
width)))))
|
|
|
|
(defun ebox--measurement-face-remapping ()
|
|
"Return face remaps that can affect geometric text measurement.
|
|
TP paint slots are stable paint-layer addresses. Their foreground/background
|
|
updates change redisplay without changing glyph geometry, so installing or
|
|
switching those layers must not invalidate Ebox measurement caches."
|
|
(when (boundp 'face-remapping-alist)
|
|
(cl-remove-if
|
|
(lambda (entry)
|
|
(let ((face (car-safe entry)))
|
|
(and (symbolp face)
|
|
(string-match-p "\\`tp-paint-slot-" (symbol-name face)))))
|
|
face-remapping-alist)))
|
|
|
|
;;;###autoload
|
|
(defun ebox-string-pixel-width (string)
|
|
"Return the display pixel width of STRING's first line.
|
|
This is the public, text-scale-aware measurement used by Ebox layout code."
|
|
(ebox--string-pixel-width string))
|
|
|
|
(defun ebox--display-signature ()
|
|
"Return the current display signature for pixel measurement caches."
|
|
(list (frame-parameter nil 'font)
|
|
(frame-parameter nil 'font-backend)
|
|
(frame-parameter nil 'line-spacing)
|
|
(frame-parameter nil 'internal-border-width)
|
|
(face-attribute 'default :family nil t)
|
|
(face-attribute 'default :height nil t)
|
|
(face-attribute 'default :weight nil t)
|
|
(face-attribute 'default :slant nil t)
|
|
(face-attribute 'default :width nil t)
|
|
(face-attribute 'default :foreground nil t)
|
|
(face-attribute 'default :background nil t)
|
|
(ebox--measurement-face-remapping)
|
|
(and (boundp 'text-scale-mode-amount) text-scale-mode-amount)))
|
|
|
|
(defsubst ebox--current-display-signature ()
|
|
"Return the render snapshot or sample the current display signature."
|
|
(or ebox--render-display-signature
|
|
(ebox--display-signature)))
|
|
|
|
;;;###autoload
|
|
(defun ebox-display-signature ()
|
|
"Return the current display signature used for pixel measurements."
|
|
(ebox--display-signature))
|
|
|
|
(defun ebox--validate-display-cache ()
|
|
"Clear pixel caches when the current display signature changed."
|
|
(let ((signature (ebox--current-display-signature)))
|
|
(unless (equal signature ebox--display-signature-cache)
|
|
(clrhash ebox--char-width-cache)
|
|
(ebox--clear-string-pixel-width-cache)
|
|
(setq ebox--space-pixel-cache nil)
|
|
(setq ebox--face-height-width-cache nil)
|
|
(setq ebox--display-signature-cache signature))))
|
|
|
|
(defun ebox--ensure-display-cache-current ()
|
|
"Validate the display cache unless it was already validated for this render.
|
|
Rebuilding the display signature is comparatively expensive, so a single
|
|
render validates it once via `ebox--with-validated-display-cache' and every
|
|
inner measurement skips the check."
|
|
(unless ebox--display-cache-validated
|
|
(ebox--validate-display-cache)))
|
|
|
|
(defmacro ebox--with-validated-display-cache (&rest body)
|
|
"Validate the display cache once, then run BODY without re-validating.
|
|
Nested uses reuse the outer validation."
|
|
(declare (indent 0) (debug t))
|
|
`(if ebox--display-cache-validated
|
|
(progn ,@body)
|
|
(let ((ebox--display-cache-validated t)
|
|
(ebox--render-display-signature
|
|
(or ebox--render-display-signature
|
|
(ebox--display-signature))))
|
|
(let ((ebox--render-string-pixel-width-cache
|
|
(make-hash-table :test 'equal))
|
|
(ebox--render-string-max-pixel-width-cache
|
|
(make-hash-table :test 'eq))
|
|
(ebox--render-recached-source-node-cache
|
|
(make-hash-table :test 'eq)))
|
|
(ebox--validate-display-cache)
|
|
,@body))))
|
|
|
|
(defsubst ebox--char-pixel-width (char)
|
|
"Get the pixel width of CHAR, using cache."
|
|
(ebox--ensure-display-cache-current)
|
|
(or (gethash char ebox--char-width-cache)
|
|
(puthash char (ebox--string-pixel-width (char-to-string char))
|
|
ebox--char-width-cache)))
|
|
|
|
(defsubst ebox--substring-pixel-width (string start end)
|
|
"Return STRING width from START to END, preserving text properties."
|
|
(ebox--string-pixel-width (substring string start end)))
|
|
|
|
(defun ebox--space-pixel-width ()
|
|
"Get the pixel width of a space character, using cache."
|
|
(ebox--ensure-display-cache-current)
|
|
(or ebox--space-pixel-cache
|
|
(setq ebox--space-pixel-cache (ebox--string-pixel-width " "))))
|
|
|
|
;;;###autoload
|
|
(defun ebox-clear-cache ()
|
|
"Clear pixel width caches after font or named face metrics change."
|
|
(clrhash ebox--char-width-cache)
|
|
(ebox--clear-string-pixel-width-cache)
|
|
(setq ebox--space-pixel-cache nil)
|
|
(setq ebox--face-height-width-cache nil)
|
|
(setq ebox--display-signature-cache (ebox--display-signature)))
|
|
|
|
(defun ebox--init-ascii-cache ()
|
|
"Pre-calculate cache for ASCII characters."
|
|
(dotimes (i 128)
|
|
(ebox--char-pixel-width i))
|
|
(ebox--space-pixel-width))
|
|
|
|
(defsubst ebox-pixel-space (pixel-width)
|
|
"Generate a space string with PIXEL-WIDTH."
|
|
(if (or (null pixel-width) (<= pixel-width 0))
|
|
""
|
|
(let ((display
|
|
(ebox--register-render-owned-text-value
|
|
'display `(space :width (,pixel-width)))))
|
|
(propertize " " 'display display))))
|
|
|
|
(defun ebox--pixel-blank (pixel-width height)
|
|
"Generate a blank area of PIXEL-WIDTH and HEIGHT lines."
|
|
(when (and pixel-width height (> pixel-width 0) (> height 0))
|
|
(ebox--string-repeat-lines (ebox-pixel-space pixel-width) height)))
|
|
|
|
(defun ebox--pixel-pad (string prefix-pixel &optional suffix-pixel)
|
|
"Pad STRING with pixel spaces on left (PREFIX-PIXEL) and right (SUFFIX-PIXEL)."
|
|
(concat (ebox-pixel-space (or prefix-pixel 0))
|
|
string
|
|
(ebox-pixel-space (or suffix-pixel 0))))
|
|
|
|
(defun ebox--pixel-reach (string total-pixel &optional align)
|
|
"Extend STRING to TOTAL-PIXEL width.
|
|
ALIGN can be `left', `center', or `right'."
|
|
(let* ((string-pixel (ebox--string-pixel-width string))
|
|
(rest-pixel (max 0 (- total-pixel string-pixel)))
|
|
(align (or align 'left))
|
|
(left-pixel (pcase align
|
|
('left 0)
|
|
('right rest-pixel)
|
|
('center (/ rest-pixel 2))
|
|
(_ 0))))
|
|
(ebox--pixel-pad string left-pixel (- rest-pixel left-pixel))))
|
|
|
|
(defsubst ebox-pixel-left (string total-pixel)
|
|
"Align STRING to the left within TOTAL-PIXEL."
|
|
(ebox--pixel-reach string total-pixel 'left))
|
|
|
|
(defsubst ebox-pixel-center (string total-pixel)
|
|
"Align STRING to the center within TOTAL-PIXEL."
|
|
(ebox--pixel-reach string total-pixel 'center))
|
|
|
|
(defsubst ebox-pixel-right (string total-pixel)
|
|
"Align STRING to the right within TOTAL-PIXEL."
|
|
(ebox--pixel-reach string total-pixel 'right))
|
|
|
|
;; Initialize measurement cache after all measurement helpers are defined.
|
|
(ebox--init-ascii-cache)
|
|
|
|
(provide 'ebox-measure)
|
|
|
|
;;; ebox-measure.el ends here
|