ebox/ebox-size.el
Kinneyzhang 79f5bc23d1 feat: add CSS sizing and native text interaction capabilities
Normalize size units and intrinsic sizing across Elisp and native layout. Add help, pointer, hover-style and keymap support with reusable interaction adapters.

Keep content updates local, preserve scroll caches and hover borders, and avoid rebuilding retained plans and ownership metadata for stable geometry.

Validation: make check and native-rust-tests passed; targeted native interaction and scroll publication regressions passed.
2026-09-09 22:25:18 +08:00

231 lines
10 KiB
EmacsLisp

;;; ebox-size.el --- Explicit CSS length values for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Pure validation and pixel resolution for (UNIT NUMBER) lengths and
;; calc/min/max/clamp expressions. Callers supply measured context and own
;; property keywords, range constraints, and final display quantization.
;;; Code:
(require 'cl-lib)
(define-error 'ebox-size-error "Invalid Ebox size")
(defconst ebox-size-axis-units
'((inline px ch vw %)
(block lh vh %)
(stroke px ch lh vw vh)
(length px ch vw lh vh %))
"Allowed units for each Ebox size domain.
Inline and block lengths follow the renderer's horizontal and line axes.
Stroke thickness is a paint measurement independent of edge orientation.
Length is only for declarations whose parent-dependent axis is not known yet.")
(defun ebox-size--finite-number-p (value)
"Return non-nil if VALUE is a finite real number."
(and (numberp value)
(or (integerp value) (= (- value value) 0.0))))
(defun ebox-size--fail (value reason)
"Reject VALUE with a size error explaining REASON."
(signal 'ebox-size-error (list reason value)))
(defun ebox-size--checked-number (value source)
"Return finite VALUE, or reject SOURCE for arithmetic overflow."
(unless (ebox-size--finite-number-p value)
(ebox-size--fail source "Expected a finite number"))
value)
(defun ebox-size--unit (value context)
"Read a unit VALUE using pixel measurements in CONTEXT.
Return (DIMENSION PIXELS DEPENDENCIES), with nil PIXELS when unresolved."
(unless (and (= (length value) 2)
(ebox-size--finite-number-p (cadr value)))
(ebox-size--fail value "Use (unit finite-number)"))
(let* ((unit (car value))
(amount (cadr value))
(dependency (pcase unit
('% 'percent)
('vw 'viewport-width)
('vh 'viewport-height)
('ch 'ch)
('lh 'lh)))
(base (pcase unit
('px 1)
('% (plist-get context :percent-base))
('vw (plist-get context :viewport-width))
('vh (plist-get context :viewport-height))
('ch (plist-get context :ch))
('lh (plist-get context :lh)))))
(when (and base
(not (and (ebox-size--finite-number-p base) (>= base 0))))
(ebox-size--fail value "Context measurements must be finite and nonnegative"))
(list 'length
(when base
(ebox-size--checked-number
(if (memq unit '(% vw vh))
(/ (* amount base) 100.0)
(* amount base))
value))
(when dependency (list dependency)))))
(defun ebox-size--same-dimension (arguments source)
"Return the shared dimension of ARGUMENTS, or reject SOURCE."
(let ((dimension (caar arguments)))
(unless (cl-every (lambda (argument) (eq (car argument) dimension))
arguments)
(ebox-size--fail source "Cannot add or compare numbers and lengths"))
dimension))
(defun ebox-size--math (value context)
"Read mathematical VALUE in CONTEXT without evaluating Lisp."
(let* ((operator (car value))
(count (length (cdr value))))
(unless (pcase operator
((or '* '/) (= count 2))
('clamp (= count 3))
('calc (= count 1))
(_ (> count 0)))
(ebox-size--fail value "Wrong number of size function arguments"))
(let* ((arguments (mapcar (lambda (argument)
(ebox-size--read argument context t))
(cdr value)))
(numbers (mapcar #'cadr arguments))
(dimensions (mapcar #'car arguments))
(dimension
(pcase operator
('*
(when (> (cl-count 'length dimensions) 1)
(ebox-size--fail value "Multiplication needs a scalar operand"))
(if (memq 'length dimensions) 'length 'number))
('/
(unless (eq (cadr dimensions) 'number)
(ebox-size--fail value "Division needs a scalar divisor"))
(when (zerop (cadr numbers))
(ebox-size--fail value "Division by zero"))
(car dimensions))
(_ (ebox-size--same-dimension arguments value))))
(dependencies (delete-dups
(apply #'append (mapcar #'caddr arguments)))))
(list dimension
(when (cl-every #'numberp numbers)
(ebox-size--checked-number
(pcase operator
('calc (car numbers))
('+ (apply #'+ numbers))
('- (apply #'- numbers))
('* (apply #'* numbers))
('/ (/ (float (car numbers)) (cadr numbers)))
('min (apply #'min numbers))
('max (apply #'max numbers))
('clamp (max (nth 0 numbers)
(min (nth 1 numbers) (nth 2 numbers)))))
value))
dependencies))))
(defun ebox-size--read (value context in-math)
"Read VALUE with CONTEXT and scalar permission IN-MATH.
The result is (DIMENSION PIXELS DEPENDENCIES); nil PIXELS is unresolved."
(cond
((and in-math (ebox-size--finite-number-p value))
(list 'number value nil))
((not (and (consp value) (proper-list-p value)))
(ebox-size--fail value "Use an explicit (unit number) length or size function"))
((memq (car value) '(px % vw vh ch lh))
(ebox-size--unit value context))
((or (memq (car value) '(calc min max clamp))
(and in-math (memq (car value) '(+ - * /))))
(ebox-size--math value context))
(t (ebox-size--fail value "Unknown size unit or function"))))
(defun ebox-size--parse (value context)
"Read a length VALUE with CONTEXT, rejecting dimensionless results."
(let ((result (ebox-size--read value context nil)))
(unless (eq (car result) 'length)
(ebox-size--fail value "A size expression must produce a length"))
result))
(cl-defun ebox-size-value-p (value &optional allow-negative (allow-percent t))
"Return non-nil when VALUE is a supported length or size expression.
Supported units are px, %, vw, vh, ch, and lh, each written (UNIT NUMBER).
Supported functions are calc, min, max, and clamp. Inside functions,
+ and - combine like dimensions; * and / accept scalar operands.
Bare numbers, old singleton pixel lists, and keywords are not lengths.
ALLOW-NEGATIVE permits negative literal lengths; arithmetic intermediates
may always be negative. Callers enforce the resolved property range.
ALLOW-PERCENT defaults to t; explicit nil forbids percentages anywhere
in the expression, for properties such as border widths."
(condition-case nil
(let ((result (ebox-size--parse value nil)))
(and (or allow-negative
(not (memq (car value) '(px % vw vh ch lh)))
(>= (cadr value) 0))
(or allow-percent (not (memq 'percent (caddr result))))
t))
(error nil)))
(defun ebox-size-validate-for-axis (value property axis)
"Return length VALUE after validating PROPERTY's size AXIS.
AXIS is inline, block, stroke, or length as in `ebox-size-axis-units'.
Every unit inside calc, min, max, and clamp must be allowed on that axis,
including operands whose value would later cancel out. Invalid syntax,
negative literals, and forbidden units signal `ebox-size-error' with the
property name and allowed units. Keywords are validated by the property."
(let ((allowed (or (cdr (assq axis ebox-size-axis-units))
(error "Unknown Ebox size axis: %S" axis))))
(cl-labels ((units-valid-p (expression)
(or (atom expression)
(if (memq (car expression) '(px ch vw lh vh %))
(memq (car expression) allowed)
(cl-every #'units-valid-p (cdr expression))))))
(unless (and (ebox-size-value-p value nil (memq '% allowed))
(units-valid-p value))
(signal 'ebox-size-error
(list (format "Invalid value for %S; allowed units: %S"
property allowed)
value)))))
value)
(defun ebox-size-value-for-axis-p (value axis)
"Return non-nil when length VALUE is valid for size AXIS."
(condition-case nil
(progn (ebox-size-validate-for-axis value 'size axis) t)
(ebox-size-error nil)))
(defun ebox-size-resolve (value context)
"Resolve length VALUE to pixels using CONTEXT, or nil if unresolved.
CONTEXT is a plist of pixel measurements: :percent-base is the containing
block reference, :viewport-width and :viewport-height are viewport sizes,
and :ch and :lh are the effective font's zero advance and line height.
Missing or nil measurements defer resolution; zero is a valid measurement.
Fractional and negative results are preserved. The caller owns range
clamping and display rounding. Invalid VALUE signals `ebox-size-error'."
(cadr (ebox-size--parse value context)))
(defun ebox-size-dependencies (value)
"Return the measurements required by length VALUE in first-use order.
Possible symbols are percent, viewport-width, viewport-height, ch, and lh.
Each appears at most once. Invalid VALUE signals `ebox-size-error'."
(caddr (ebox-size--parse value nil)))
(defun ebox-size-quantize (values)
"Quantize nonnegative VALUES at cumulative display boundaries.
The returned whole extents preserve the floor of the combined extent instead
of losing each separate fraction. A small tolerance absorbs floating-point
error at integer boundaries. This is a display operation, not CSS arithmetic."
(let ((position 0.0) (previous 0))
(mapcar
(lambda (value)
(unless (and (ebox-size--finite-number-p value) (>= value 0))
(ebox-size--fail value "Display extents must be finite and nonnegative"))
(setq position (ebox-size--checked-number (+ position value) values))
(let* ((boundary (floor (+ position 1e-9)))
(extent (- boundary previous)))
(setq previous boundary)
extent))
values)))
(provide 'ebox-size)
;;; ebox-size.el ends here