diff --git a/ebox-flex.el b/ebox-flex.el index 9d66726..e342090 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -86,6 +86,34 @@ :gap :row-gap :column-gap) "Properties that belong only to flex container layout.") +(defconst ebox--flex-config-prop-keys + '(:flex-direction :flex-wrap :justify-content + :align-items :align-content :row-gap :column-gap) + "Canonical property order stored in a typed FlexConfig.") + +(defconst ebox--flex-direction-values + '(row row-reverse column column-reverse) + "Supported canonical FlexConfig direction values.") + +(defconst ebox--flex-wrap-values + '(nowrap wrap wrap-reverse) + "Supported canonical FlexConfig wrapping values.") + +(defconst ebox--flex-justify-content-values + '(flex-start flex-end center space-between space-around space-evenly + start end left right normal) + "Supported canonical FlexConfig main-axis alignment values.") + +(defconst ebox--flex-align-items-values + '(stretch flex-start flex-end center baseline normal + start end self-start self-end) + "Supported canonical FlexConfig item alignment values.") + +(defconst ebox--flex-align-content-values + '(stretch flex-start flex-end center space-between space-around space-evenly + baseline normal start end) + "Supported canonical FlexConfig line alignment values.") + (defconst ebox--flex-container-neutral-box-prop-keys '(:width :height :box-sizing) "Flex box properties that do not create visible wrapping by themselves.") @@ -267,6 +295,27 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed." (cons (nth 0 gap) (nth 1 gap))) (t (cons gap gap)))) +(defun ebox--flex-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 FlexConfig %S has invalid value: %S" property resolved)) + resolved)) + +(defun ebox--flex-row-gap-value (value) + "Return canonical nonnegative line count for FlexConfig row gap VALUE." + (unless (or (null value) + (numberp value) + (and (consp value) + (numberp (car value)) + (null (cdr value)))) + (error "Ebox FlexConfig :row-gap is invalid: %S" value)) + (or (ebox--flex-line-value value 0) 0)) + +(defun ebox--flex-column-gap-value (value) + "Return canonical nonnegative pixels for FlexConfig column gap VALUE." + (or (ebox--nonnegative-horizontal-size-pixels value 0) 0)) + (defun ebox--flex-normalize-config-props (props) "Normalize FlexConfig PROPS to explicit layout-owned fields." (let* ((flow (plist-get props :flex-flow)) @@ -280,23 +329,41 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed." (memq value '(nowrap wrap wrap-reverse))) flow-values)) (gap-pair (ebox--flex-gap-pair (plist-get props :gap))) - (direction (or (plist-get props :flex-direction) - flow-direction - 'row)) - (wrap (or (plist-get props :flex-wrap) - flow-wrap - 'nowrap))) + (direction + (ebox--flex-enum-value + (or (plist-get props :flex-direction) flow-direction) + 'row ebox--flex-direction-values :flex-direction)) + (wrap + (ebox--flex-enum-value + (or (plist-get props :flex-wrap) flow-wrap) + 'nowrap ebox--flex-wrap-values :flex-wrap)) + (justify + (ebox--flex-enum-value + (plist-get props :justify-content) 'flex-start + ebox--flex-justify-content-values :justify-content)) + (align-items + (ebox--flex-enum-value + (plist-get props :align-items) 'stretch + ebox--flex-align-items-values :align-items)) + (align-content + (ebox--flex-enum-value + (plist-get props :align-content) 'stretch + ebox--flex-align-content-values :align-content)) + (row-gap + (if (plist-member props :row-gap) + (plist-get props :row-gap) + (car gap-pair))) + (column-gap + (if (plist-member props :column-gap) + (plist-get props :column-gap) + (cdr gap-pair)))) (list :flex-direction direction :flex-wrap wrap - :justify-content (or (plist-get props :justify-content) 'flex-start) - :align-items (or (plist-get props :align-items) 'stretch) - :align-content (or (plist-get props :align-content) 'stretch) - :row-gap (if (plist-member props :row-gap) - (plist-get props :row-gap) - (car gap-pair)) - :column-gap (if (plist-member props :column-gap) - (plist-get props :column-gap) - (cdr gap-pair))))) + :justify-content justify + :align-items align-items + :align-content align-content + :row-gap (ebox--flex-row-gap-value row-gap) + :column-gap (ebox--flex-column-gap-value column-gap)))) (defun ebox--flex-normalize-container-props (props) "Normalize legacy flex container PROPS to config plus frame constraints." @@ -306,18 +373,59 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed." (defconst ebox--flex-default-config-props (ebox--flex-normalize-config-props nil) - "Validated property set for the first default FlexConfig slice.") + "Computed default property set for FlexConfig.") + +(defun ebox--flex-validate-config-input (plist) + "Return canonical FlexConfig input PLIST after surface validation." + (unless (and (proper-list-p plist) (zerop (% (length plist) 2))) + (error "Ebox FlexConfig 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--flex-config-prop-keys) + do (error "Ebox FlexConfig does not accept %S" key) + do (when (gethash key seen) + (error "Duplicate Ebox FlexConfig property: %S" key)) + do (puthash key t seen))) + plist) (defun ebox-flex-layout-config-props-p (props) - "Return non-nil when PROPS are the validated default FlexConfig." - (equal props ebox--flex-default-config-props)) + "Return non-nil when PROPS are canonical validated FlexConfig data." + (and (proper-list-p props) + (zerop (% (length props) 2)) + (let ((cursor props) + (keys ebox--flex-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))) + (memq (plist-get props :flex-direction) + ebox--flex-direction-values) + (memq (plist-get props :flex-wrap) + ebox--flex-wrap-values) + (memq (plist-get props :justify-content) + ebox--flex-justify-content-values) + (memq (plist-get props :align-items) + ebox--flex-align-items-values) + (memq (plist-get props :align-content) + ebox--flex-align-content-values) + (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))))) ;;;###autoload -(defun ebox-flex-layout-create () - "Return the canonical Flex layout config with computed defaults." +(defun ebox-flex-layout-create (&rest plist) + "Return a typed FlexConfig from canonical evaluated PLIST. +PLIST accepts direction, wrap, alignment, and row/column gap longhands. +Author shorthands such as `:flex-flow' and `:gap' are normalized before this +programmatic port and are therefore rejected here." + (ebox--flex-validate-config-input plist) (ebox-layout-config--create :kind 'flex - :props (copy-sequence ebox--flex-default-config-props))) + :props (ebox--flex-normalize-config-props plist))) (defun ebox--flex-axis (props) "Return main axis for normalized flex PROPS." diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el index 9bca977..07e580b 100644 --- a/tests/ebox-dsl-tests.el +++ b/tests/ebox-dsl-tests.el @@ -229,7 +229,7 @@ (equal (ebox-layout-config-props layout) '(:flex-direction row :flex-wrap nowrap :justify-content flex-start :align-items stretch - :align-content stretch :row-gap nil :column-gap nil))) + :align-content stretch :row-gap 0 :column-gap 0))) (should-not (plist-member (ebox-layout-config-props layout) :width)) (should-not (plist-member (ebox-layout-config-props layout) :height)) (should (ebox-box-node-p box)) @@ -257,7 +257,7 @@ (setf (ebox-layout-config-props invalid-direction) '(:flex-direction bogus :flex-wrap nowrap :justify-content flex-start :align-items stretch - :align-content stretch :row-gap nil :column-gap nil)) + :align-content stretch :row-gap 0 :column-gap 0)) (setf (ebox-layout-config-props extra-frame-prop) (append (ebox-layout-config-props extra-frame-prop) '(:width 999))) @@ -289,6 +289,63 @@ (should (equal (ebox-layout-config-props (ebox-box-node-layout box)) ebox--flex-default-config-props)))) +(ert-deftest ebox-flex-layout-create-normalizes-the-complete-config-schema () + "The typed Flex constructor should emit one canonical closed property set." + (let ((layout + (ebox-flex-layout-create + :flex-direction 'column-reverse + :flex-wrap 'wrap + :justify-content 'space-between + :align-items 'center + :align-content 'end + :row-gap '(2) + :column-gap '(8)))) + (should + (equal (ebox-layout-config-props layout) + '(:flex-direction column-reverse :flex-wrap wrap + :justify-content space-between :align-items center + :align-content end :row-gap 2 :column-gap 8))) + (should (ebox-flex-layout-config-props-p + (ebox-layout-config-props layout))) + (should (ebox-box-node-p + (ebox-box-create :layout layout :children nil))))) + +(ert-deftest ebox-flex-layout-create-validates-values-and-rejects-author-sugar () + "FlexConfig should accept its value domain and reject sugar or foreign props." + (dolist (direction ebox--flex-direction-values) + (should (ebox-flex-layout-create :flex-direction direction))) + (dolist (wrap ebox--flex-wrap-values) + (should (ebox-flex-layout-create :flex-wrap wrap))) + (dolist (justify ebox--flex-justify-content-values) + (should (ebox-flex-layout-create :justify-content justify))) + (dolist (align ebox--flex-align-items-values) + (should (ebox-flex-layout-create :align-items align))) + (dolist (align ebox--flex-align-content-values) + (should (ebox-flex-layout-create :align-content align))) + (dolist (plist '((:flex-direction bogus) + (:flex-wrap reverse) + (:justify-content bogus) + (:align-items bogus) + (:align-content bogus) + (:row-gap -1) + (:column-gap (-1)) + (:flex-flow (row wrap)) + (:gap 1) + (:width 100) + (:flex-direction row :flex-direction column))) + (should-error (apply #'ebox-flex-layout-create plist) :type 'error))) + +(ert-deftest ebox-flex-config-validation-does-not-reparse-canonical-pixels () + "Boundary validation should preserve physical pixels under non-unit fonts." + (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8))) + (let* ((layout (ebox-flex-layout-create :column-gap '(8))) + (box (ebox-box-create :layout layout :children nil))) + (should (= (plist-get (ebox-layout-config-props layout) :column-gap) 8)) + (should (= (plist-get + (ebox-layout-config-props (ebox-box-node-layout box)) + :column-gap) + 8))))) + (ert-deftest ebox-canonical-flex-projects-one-box-frame-constraint () "Typed and legacy Flex should derive the same content size from BoxFrame." (dolist (sizing '(border-box content-box))