feat: validate canonical GridConfig track grammar

This commit is contained in:
Kinneyzhang 2026-08-26 19:56:20 +08:00
parent 0a552293f0
commit 5b55c00015
5 changed files with 307 additions and 28 deletions

View File

@ -36,6 +36,26 @@
:justify-items :align-items :justify-content :align-content) :justify-items :align-items :justify-content :align-content)
"Properties owned by the grid formatting context.") "Properties owned by the grid formatting context.")
(defconst ebox--grid-config-prop-keys
'(:grid-template-columns :grid-template-rows
:grid-auto-columns :grid-auto-rows :grid-auto-flow
:row-gap :column-gap
:justify-items :align-items :justify-content :align-content)
"Canonical property order stored in a typed GridConfig.")
(defconst ebox--grid-justify-items-values
'(stretch normal start end left right center)
"Supported canonical GridConfig inline item alignment values.")
(defconst ebox--grid-align-items-values
'(stretch normal start end top bottom center)
"Supported canonical GridConfig block item alignment values.")
(defconst ebox--grid-content-alignment-values
'(stretch normal start end left right center
flex-start flex-end space-between space-around space-evenly)
"Supported canonical GridConfig content alignment values.")
(defconst ebox--grid-item-prop-keys (defconst ebox--grid-item-prop-keys
'(:grid-column :grid-row :grid-column-span :grid-row-span) '(:grid-column :grid-row :grid-column-span :grid-row-span)
"Properties that describe a child's position in a grid.") "Properties that describe a child's position in a grid.")
@ -91,8 +111,11 @@
((and (symbolp value) ((and (symbolp value)
(string-match "\\`\\([0-9]+\\(?:\\.[0-9]+\\)?\\)fr\\'" (string-match "\\`\\([0-9]+\\(?:\\.[0-9]+\\)?\\)fr\\'"
(symbol-name value))) (symbol-name value)))
(list :kind 'fr :factor (let ((factor
(string-to-number (match-string 1 (symbol-name value))))) (string-to-number (match-string 1 (symbol-name value)))))
(unless (> factor 0)
(error "ebox-grid: fractional track must be positive: %S" value))
(list :kind 'fr :factor factor)))
((and (consp value) (eq (car value) 'fr)) ((and (consp value) (eq (car value) 'fr))
(unless (and (numberp (cadr value)) (> (cadr value) 0)) (unless (and (numberp (cadr value)) (> (cadr value) 0))
(error "ebox-grid: invalid fractional track %S" value)) (error "ebox-grid: invalid fractional track %S" value))
@ -104,17 +127,30 @@
(error "ebox-grid: invalid minmax track %S" value)) (error "ebox-grid: invalid minmax track %S" value))
(let ((minimum (ebox-grid--normalize-track (cadr value) axis)) (let ((minimum (ebox-grid--normalize-track (cadr value) axis))
(maximum (ebox-grid--normalize-track (caddr value) axis))) (maximum (ebox-grid--normalize-track (caddr value) axis)))
(unless (memq (plist-get minimum :kind) '(fixed auto))
(error "ebox-grid: minmax minimum must be fixed or auto: %S" value))
(unless (memq (plist-get maximum :kind) '(fixed auto fr))
(error "ebox-grid: minmax maximum cannot be nested: %S" value))
(when (and (eq (plist-get maximum :kind) 'fixed) (when (and (eq (plist-get maximum :kind) 'fixed)
(eq (plist-get minimum :kind) 'fixed) (eq (plist-get minimum :kind) 'fixed)
(> (plist-get minimum :size) (plist-get maximum :size))) (> (plist-get minimum :size) (plist-get maximum :size)))
(error "ebox-grid: minmax minimum exceeds maximum: %S" value)) (error "ebox-grid: minmax minimum exceeds maximum: %S" value))
(list :kind 'minmax :min minimum :max maximum))) (list :kind 'minmax :min minimum :max maximum)))
((eq axis 'columns) ((and (eq axis 'columns)
(or (numberp value)
(and (consp value)
(numberp (car value))
(null (cdr value)))))
(list :kind 'fixed (list :kind 'fixed
:size (ebox--nonnegative-horizontal-size-pixels value 0))) :size (ebox--nonnegative-horizontal-size-pixels value 0)))
(t ((and (eq axis 'rows)
(or (numberp value)
(and (consp value)
(numberp (car value))
(null (cdr value)))))
(let ((lines (ebox--flex-line-value value 0))) (let ((lines (ebox--flex-line-value value 0)))
(list :kind 'fixed :size lines))))) (list :kind 'fixed :size lines)))
(t (error "ebox-grid: unsupported %S track: %S" axis value))))
(defun ebox-grid--normalize-tracks (value axis) (defun ebox-grid--normalize-tracks (value axis)
"Normalize a track template VALUE along AXIS." "Normalize a track template VALUE along AXIS."
@ -138,24 +174,150 @@
(cons (or (ebox--flex-line-value row 0) 0) (cons (or (ebox--flex-line-value row 0) 0)
(or (ebox--nonnegative-horizontal-size-pixels column 0) 0)))) (or (ebox--nonnegative-horizontal-size-pixels column 0) 0))))
(defun ebox-grid--enum-value (value default allowed property)
"Return VALUE or DEFAULT after checking ALLOWED values for PROPERTY."
(let ((resolved (or value default)))
(unless (memq resolved allowed)
(error "Ebox GridConfig %S has invalid value: %S" property resolved))
resolved))
(defun ebox-grid--one-auto-track (value axis property)
"Return VALUE normalized as zero or one auto track for AXIS and PROPERTY."
(let ((tracks (ebox-grid--normalize-tracks value axis)))
(when (> (length tracks) 1)
(error "Ebox GridConfig %S accepts at most one track" property))
(car tracks)))
(defun ebox-grid--normalize-config-props (props)
"Normalize GridConfig PROPS once to canonical layout-owned facts."
(let ((gaps (ebox-grid--gap-pair props)))
(list
:grid-template-columns
(ebox-grid--normalize-tracks
(plist-get props :grid-template-columns) 'columns)
:grid-template-rows
(ebox-grid--normalize-tracks
(plist-get props :grid-template-rows) 'rows)
:grid-auto-columns
(ebox-grid--one-auto-track
(plist-get props :grid-auto-columns) 'columns :grid-auto-columns)
:grid-auto-rows
(ebox-grid--one-auto-track
(plist-get props :grid-auto-rows) 'rows :grid-auto-rows)
:grid-auto-flow
(ebox-grid--enum-value
(plist-get props :grid-auto-flow) 'row '(row column) :grid-auto-flow)
:row-gap (car gaps)
:column-gap (cdr gaps)
:justify-items
(ebox-grid--enum-value
(plist-get props :justify-items) 'stretch
ebox--grid-justify-items-values :justify-items)
:align-items
(ebox-grid--enum-value
(plist-get props :align-items) 'stretch
ebox--grid-align-items-values :align-items)
:justify-content
(ebox-grid--enum-value
(plist-get props :justify-content) 'start
ebox--grid-content-alignment-values :justify-content)
:align-content
(ebox-grid--enum-value
(plist-get props :align-content) 'start
ebox--grid-content-alignment-values :align-content))))
(defconst ebox--grid-default-config-props (defconst ebox--grid-default-config-props
'(:grid-template-columns nil :grid-template-rows nil (ebox-grid--normalize-config-props nil)
:grid-auto-columns nil :grid-auto-rows nil :grid-auto-flow row "Computed default property set for GridConfig.")
:row-gap 0 :column-gap 0
:justify-items stretch :align-items stretch (defun ebox-grid--canonical-atomic-track-p (track allowed-kinds)
:justify-content start :align-content start) "Return non-nil when TRACK is canonical and its kind is in ALLOWED-KINDS."
"Validated property set for the first default GridConfig slice.") (and (proper-list-p track)
(memq (plist-get track :kind) allowed-kinds)
(pcase (plist-get track :kind)
('auto (equal track '(:kind auto :factor 0)))
('fixed
(let ((size (plist-get track :size)))
(and (numberp size) (>= size 0)
(equal track (list :kind 'fixed :size size)))))
('fr
(let ((factor (plist-get track :factor)))
(and (numberp factor) (> factor 0)
(equal track (list :kind 'fr :factor factor)))))
(_ nil))))
(defun ebox-grid--canonical-track-p (track)
"Return non-nil when TRACK is one canonical top-level track descriptor."
(or (ebox-grid--canonical-atomic-track-p track '(fixed auto fr))
(and (proper-list-p track)
(eq (plist-get track :kind) 'minmax)
(ebox-grid--canonical-atomic-track-p
(plist-get track :min) '(fixed auto))
(ebox-grid--canonical-atomic-track-p
(plist-get track :max) '(fixed auto fr))
(equal track
(list :kind 'minmax
:min (plist-get track :min)
:max (plist-get track :max))))))
(defun ebox-grid-layout-config-props-p (props) (defun ebox-grid-layout-config-props-p (props)
"Return non-nil when PROPS are the validated default GridConfig." "Return non-nil when PROPS are canonical validated GridConfig data."
(equal props ebox--grid-default-config-props)) (and (proper-list-p props)
(zerop (% (length props) 2))
(let ((cursor props) (keys ebox--grid-config-prop-keys) valid)
(setq valid t)
(while (and valid keys)
(setq valid (eq (car cursor) (car keys))
cursor (cddr cursor)
keys (cdr keys)))
(and valid (null cursor)))
(cl-every #'ebox-grid--canonical-track-p
(plist-get props :grid-template-columns))
(cl-every #'ebox-grid--canonical-track-p
(plist-get props :grid-template-rows))
(let ((auto-column (plist-get props :grid-auto-columns))
(auto-row (plist-get props :grid-auto-rows)))
(and (or (null auto-column)
(ebox-grid--canonical-track-p auto-column))
(or (null auto-row)
(ebox-grid--canonical-track-p auto-row))))
(memq (plist-get props :grid-auto-flow) '(row column))
(let ((row-gap (plist-get props :row-gap))
(column-gap (plist-get props :column-gap)))
(and (integerp row-gap) (>= row-gap 0)
(numberp column-gap) (>= column-gap 0)))
(memq (plist-get props :justify-items)
ebox--grid-justify-items-values)
(memq (plist-get props :align-items)
ebox--grid-align-items-values)
(memq (plist-get props :justify-content)
ebox--grid-content-alignment-values)
(memq (plist-get props :align-content)
ebox--grid-content-alignment-values)))
(defun ebox-grid--validate-config-input (plist)
"Return canonical GridConfig input PLIST after surface validation."
(unless (and (proper-list-p plist) (zerop (% (length plist) 2)))
(error "Ebox GridConfig properties must be an even plist: %S" plist))
(let ((seen (make-hash-table :test 'eq)))
(cl-loop for (key _value) on plist by #'cddr
unless (memq key ebox--grid-config-prop-keys)
do (error "Ebox GridConfig does not accept %S" key)
do (when (gethash key seen)
(error "Duplicate Ebox GridConfig property: %S" key))
do (puthash key t seen)))
plist)
;;;###autoload ;;;###autoload
(defun ebox-grid-layout-create () (defun ebox-grid-layout-create (&rest plist)
"Return the canonical Grid layout config with computed defaults." "Return a typed GridConfig from canonical evaluated PLIST.
(ebox-layout-config--create Author `:gap' sugar and legacy grid-gap aliases are normalized before this
:kind 'grid programmatic port and are therefore rejected here."
:props (copy-sequence ebox--grid-default-config-props))) (ebox-grid--validate-config-input plist)
(let ((props (ebox-grid--normalize-config-props plist)))
(unless (ebox-grid-layout-config-props-p props)
(error "Ebox GridConfig normalization produced invalid data: %S" props))
(ebox-layout-config--create :kind 'grid :props props)))
(defun ebox-grid--visual-props-p (props) (defun ebox-grid--visual-props-p (props)
"Return non-nil when PROPS contains visible box behavior." "Return non-nil when PROPS contains visible box behavior."
@ -662,15 +824,12 @@ size for start/center/end alignment unless it would overflow its track."
(defun ebox--render-grid-children (config constraints children) (defun ebox--render-grid-children (config constraints children)
"Render Grid CONFIG under frame CONSTRAINTS over flat CHILDREN." "Render Grid CONFIG under frame CONSTRAINTS over flat CHILDREN."
(let* ((props config) (let* ((props config)
(columns (ebox-grid--normalize-tracks (columns (plist-get props :grid-template-columns))
(plist-get props :grid-template-columns) 'columns)) (rows (plist-get props :grid-template-rows))
(rows (ebox-grid--normalize-tracks (auto-columns (plist-get props :grid-auto-columns))
(plist-get props :grid-template-rows) 'rows)) (auto-rows (plist-get props :grid-auto-rows))
(auto-columns (car (ebox-grid--normalize-tracks (gaps (cons (plist-get props :row-gap)
(plist-get props :grid-auto-columns) 'columns))) (plist-get props :column-gap)))
(auto-rows (car (ebox-grid--normalize-tracks
(plist-get props :grid-auto-rows) 'rows)))
(gaps (ebox-grid--gap-pair props))
(flow (or (plist-get props :grid-auto-flow) 'row)) (flow (or (plist-get props :grid-auto-flow) 'row))
(placed (ebox-grid--place-children children (placed (ebox-grid--place-children children
(max 1 (length columns)) (max 1 (length columns))
@ -723,7 +882,8 @@ size for start/center/end alignment unless it would overflow its track."
(ebox--render-grid-box (ebox--render-grid-box
node node
(ebox--render-grid-children (ebox--render-grid-children
(ebox--plist-keep-keys props ebox--grid-layout-prop-keys) (or (plist-get node :ebox-grid-config)
(error "Ebox Grid node lacks canonical config"))
(ebox-grid--legacy-constraints props wrapper) (ebox-grid--legacy-constraints props wrapper)
(ebox-tree-layout-children node))))) (ebox-tree-layout-children node)))))
@ -744,10 +904,14 @@ Children may carry `:grid-column' and `:grid-row' placement properties.
unless (memq key ebox--grid-layout-prop-keys) unless (memq key ebox--grid-layout-prop-keys)
collect key and collect value)) collect key and collect value))
(wrapper (ebox-grid--wrapper box-props)) (wrapper (ebox-grid--wrapper box-props))
(config
(ebox-grid--normalize-config-props
(ebox--plist-keep-keys raw-props ebox--grid-layout-prop-keys)))
(node (list :ebox-type 'grid (node (list :ebox-type 'grid
:display '(block grid) :display '(block grid)
:props raw-props :props raw-props
:raw-props raw-props :raw-props raw-props
:ebox-grid-config config
:ebox-style-declarations :ebox-style-declarations
(ebox-style-compile-declarations raw-props) (ebox-style-compile-declarations raw-props)
:box wrapper :box wrapper

View File

@ -16,6 +16,8 @@
(declare-function ebox-create "ebox" (&rest plist)) (declare-function ebox-create "ebox" (&rest plist))
(declare-function ebox--flex-normalize-container-props (declare-function ebox--flex-normalize-container-props
"ebox-flex" (props)) "ebox-flex" (props))
(declare-function ebox-grid--normalize-config-props
"ebox-grid" (props))
(declare-function ebox--normalize-horizontal-size-value (declare-function ebox--normalize-horizontal-size-value
"ebox-flex" (value allowed-keywords)) "ebox-flex" (value allowed-keywords))
(declare-function ebox--nonnegative-horizontal-size-pixels (declare-function ebox--nonnegative-horizontal-size-pixels
@ -1281,6 +1283,9 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied."
(if (eq type 'flex) (if (eq type 'flex)
(ebox--flex-normalize-container-props raw) (ebox--flex-normalize-container-props raw)
raw)) raw))
(when (eq type 'grid)
(plist-put node :ebox-grid-config
(ebox-grid--normalize-config-props raw)))
(ebox-style--apply-container-wrapper node style snapshot))) (ebox-style--apply-container-wrapper node style snapshot)))
(defun ebox-style--delete-node-property (node property) (defun ebox-style--delete-node-property (node property)

View File

@ -585,6 +585,7 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(append ebox-tree--runtime-source-keys (append ebox-tree--runtime-source-keys
ebox-tree--child-source-keys ebox-tree--child-source-keys
'(:ebox-style-declarations :ebox-style-overrides '(:ebox-style-declarations :ebox-style-overrides
:ebox-grid-config
:ebox-computed-style :ebox-style-wrapper :ebox-computed-style :ebox-style-wrapper
:ebox-style-generated-wrapper)) :ebox-style-generated-wrapper))
"Non-rendering keys excluded from node-local source comparison. "Non-rendering keys excluded from node-local source comparison.

View File

@ -460,6 +460,99 @@
(mapcar #'ebox--string-pixel-width (mapcar #'ebox--string-pixel-width
(ebox-string-lines legacy-rendered))))))) (ebox-string-lines legacy-rendered)))))))
(ert-deftest ebox-grid-layout-create-normalizes-the-complete-config-schema ()
"The typed Grid constructor should emit one canonical closed property set."
(let* ((layout
(ebox-grid-layout-create
:grid-template-columns '((20) 1fr)
:grid-template-rows '(1 auto)
:grid-auto-columns '((30))
:grid-auto-rows '(2)
:grid-auto-flow 'column
:row-gap '(2) :column-gap '(8)
:justify-items 'center :align-items 'end
:justify-content 'space-between :align-content 'center))
(props (ebox-layout-config-props layout)))
(should (equal (plist-get props :grid-template-columns)
'((:kind fixed :size 20) (:kind fr :factor 1))))
(should (equal (plist-get props :grid-template-rows)
'((:kind fixed :size 1) (:kind auto :factor 0))))
(should (equal (plist-get props :grid-auto-columns)
'(:kind fixed :size 30)))
(should (equal (plist-get props :grid-auto-rows)
'(:kind fixed :size 2)))
(should (= (plist-get props :row-gap) 2))
(should (= (plist-get props :column-gap) 8))
(should (ebox-grid-layout-config-props-p props))
(should (ebox-box-node-p
(ebox-box-create :layout layout :children nil)))))
(ert-deftest ebox-grid-layout-create-validates-values-and-rejects-author-sugar ()
"GridConfig should accept its value domain and reject sugar or foreign props."
(dolist (flow '(row column))
(should (ebox-grid-layout-create :grid-auto-flow flow)))
(dolist (value ebox--grid-justify-items-values)
(should (ebox-grid-layout-create :justify-items value)))
(dolist (value ebox--grid-align-items-values)
(should (ebox-grid-layout-create :align-items value)))
(dolist (value ebox--grid-content-alignment-values)
(should (ebox-grid-layout-create :justify-content value))
(should (ebox-grid-layout-create :align-content value)))
(dolist (plist '((:grid-auto-flow diagonal)
(:grid-template-columns (bogus))
(:grid-template-rows (bogus))
(:grid-template-columns (0fr))
(:grid-template-columns (0.0fr))
(:grid-template-columns ((fr 0)))
(:grid-template-columns ((minmax (fr 1) auto)))
(:grid-template-columns
((minmax (minmax (10) (20)) auto)))
(:grid-auto-columns ((10) (20)))
(:row-gap -1)
(:column-gap (-1))
(:gap 1)
(:grid-row-gap 1)
(:width 100)
(:align-items bogus)
(:grid-auto-flow row :grid-auto-flow column)))
(should-error (apply #'ebox-grid-layout-create plist) :type 'error)))
(ert-deftest ebox-grid-config-validation-does-not-reparse-canonical-pixels ()
"Grid boundary validation should preserve physical pixels under GUI fonts."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8)))
(let* ((layout
(ebox-grid-layout-create
:grid-template-columns '((20)) :column-gap '(8)))
(box (ebox-box-create :layout layout :children nil))
(props (ebox-layout-config-props (ebox-box-node-layout box))))
(should (= (plist-get props :column-gap) 8))
(should (= (plist-get (car (plist-get props :grid-template-columns))
:size)
20)))))
(ert-deftest ebox-grid-config-boundary-rejects-forged-minmax-roles ()
"Trusted Grid validation should enforce minmax roles after mutation."
(let ((fr-minimum (ebox-grid-layout-create))
(nested-minimum (ebox-grid-layout-create)))
(setf (ebox-layout-config-props fr-minimum)
(plist-put (ebox-layout-config-props fr-minimum)
:grid-template-columns
'((:kind minmax
:min (:kind fr :factor 1)
:max (:kind auto :factor 0)))))
(setf (ebox-layout-config-props nested-minimum)
(plist-put (ebox-layout-config-props nested-minimum)
:grid-template-columns
'((:kind minmax
:min (:kind minmax
:min (:kind fixed :size 1)
:max (:kind fixed :size 2))
:max (:kind auto :factor 0)))))
(should-error
(ebox-box-create :layout fr-minimum :children nil) :type 'error)
(should-error
(ebox-box-create :layout nested-minimum :children nil) :type 'error)))
(ert-deftest ebox-canonical-flex-consumes-direct-child-participation () (ert-deftest ebox-canonical-flex-consumes-direct-child-participation ()
"Flex participation should stay on child Boxes without item wrappers." "Flex participation should stay on child Boxes without item wrappers."
(let* ((left-text (ebox-text-create :value "A")) (let* ((left-text (ebox-text-create :value "A"))

View File

@ -37,6 +37,22 @@
(should (= (length lines) 1)) (should (= (length lines) 1))
(should (= (ebox--string-pixel-width (car lines)) 120)))) (should (= (ebox--string-pixel-width (car lines)) 120))))
(ert-deftest ebox-grid-repeated-render-reuses-construction-time-config ()
"Grid render and resize paths should not reparse canonical track config."
(let* ((node (ebox-grid :width '(120)
:grid-template-columns '(1fr 2fr)
(ebox-create :content "A")
(ebox-create :content "B")))
(calls 0)
(original (symbol-function 'ebox-grid--normalize-config-props)))
(cl-letf (((symbol-function 'ebox-grid--normalize-config-props)
(lambda (props)
(cl-incf calls)
(funcall original props))))
(ebox-render node)
(ebox-render node))
(should (zerop calls))))
(ert-deftest ebox-grid-minmax-fractional-columns-fill-and-shrink () (ert-deftest ebox-grid-minmax-fractional-columns-fill-and-shrink ()
"Zero-minimum fractional maxima should remain responsive to width." "Zero-minimum fractional maxima should remain responsive to width."
(dolist (width '(120 60)) (dolist (width '(120 60))