ebox/ebox-grid.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

1110 lines
50 KiB
EmacsLisp

;;; ebox-grid.el --- Grid formatting context for Ebox -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Grid is a two-dimensional layout node. It deliberately reuses Ebox's
;; shared CSS size expressions, box renderer, and retained tree identity rather
;; than introducing a second styling or measurement model.
;;; Code:
(require 'cl-lib)
(require 'seq)
(require 'subr-x)
(require 'ebox-layout-config)
(require 'ebox-layout)
(require 'ebox-flex)
(declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox-box-node-p "ebox-canonical" (node))
(declare-function ebox-get "ebox" (box property))
(declare-function ebox-node-factory--create-from-properties
"ebox-node-factory" (&rest plist))
(declare-function ebox-render "ebox-layout" (node))
(declare-function ebox--lines-concat-horizontal "ebox" (&rest strings))
(declare-function ebox-string-lines "ebox" (string))
(declare-function ebox-string-height "ebox" (string))
(declare-function ebox-lines-join "ebox" (lines))
(declare-function ebox--side-pixel "ebox-layout" (box &optional side))
(declare-function ebox--content-min-pixel "ebox-layout" (box))
(declare-function ebox--content-max-pixel "ebox-layout" (box))
(declare-function ebox--line-min-content-pixel "ebox-layout" (line))
(declare-function ebox--lines-align-vertical "ebox-layout" (string height align))
(declare-function ebox--flex-spacing "ebox-flex" (mode leftover count base-gap))
(declare-function ebox--render-with-cache "ebox-incremental" (node &optional force cache-probe))
(declare-function ebox--record-rendered-intrinsic-size
"ebox-layout" (body width height))
(declare-function ebox--rendered-intrinsic-size
"ebox-layout" (rendered axis))
(defvar ebox--region-box-table)
(defconst ebox--grid-layout-prop-keys
'(:grid-template-columns :grid-template-rows
:grid-auto-columns :grid-auto-rows :grid-auto-flow
:column-gap :row-gap :gap
:justify-items :align-items :justify-content :align-content)
"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 start end center)
"Supported canonical GridConfig inline item alignment values.")
(defconst ebox--grid-align-items-values
'(normal start end stretch flex-start flex-end center baseline)
"Supported canonical GridConfig block item alignment values.")
(defconst ebox--grid-content-alignment-values
'(normal start end stretch flex-start flex-end center
space-between space-around space-evenly)
"Supported canonical GridConfig content alignment values.")
(defconst ebox--grid-item-prop-keys
'(:grid-column :grid-row :justify-self)
"Properties that describe a child's position in a grid.")
(defun ebox-grid--split-attrs (items)
"Split grid ITEMS into a property plist and child list."
(let (props children)
(while items
(let ((item (pop items)))
(if (keywordp item)
(progn
(unless items
(error "ebox-grid: missing value for %S" item))
(push item props)
(push (pop items) props))
(push item children))))
(cons (nreverse props) (nreverse children))))
(defun ebox-grid--track-form-p (value)
"Return non-nil when VALUE is one composite track expression."
(and (consp value)
(or (memq (car value) '(fr minmax repeat))
(ebox-size-value-p value nil t))))
(defun ebox-grid--track-list (value)
"Return VALUE as a list of track expressions."
(cond
((null value) nil)
((ebox-grid--track-form-p value) (list value))
((symbolp value) (list value))
(t value)))
(defun ebox-grid--expand-repeat (tracks)
"Expand REPEAT expressions in TRACKS."
(cl-mapcan
(lambda (track)
(if (and (consp track) (eq (car track) 'repeat))
(let ((count (cadr track))
(value (caddr track)))
(unless (and (null (cdddr track))
(integerp count) (> count 0) value)
(error "ebox-grid: invalid repeat track %S" track))
(let ((body
(cond
((or (ebox-grid--track-form-p value)
(symbolp value))
(list value))
((proper-list-p value) value)
(t (error "ebox-grid: invalid repeat body %S" value)))))
(cl-loop repeat count
append (ebox-grid--expand-repeat body))))
(list track)))
tracks))
(defun ebox-grid--normalize-track (value axis &optional property)
"Normalize track VALUE for AXIS, reporting errors against PROPERTY."
(setq property (or property (if (eq axis 'columns)
:grid-template-columns :grid-template-rows)))
(cond
((eq value 'auto) '(:kind auto :factor 0))
((memq value '(min-content max-content))
(list :kind value))
((and (consp value) (eq (car value) 'fr))
(unless (and (proper-list-p value) (= (length value) 2)
(numberp (cadr value)) (> (cadr value) 0))
(error "ebox-grid: invalid fractional track %S" value))
(list :kind 'fr :factor (cadr value)))
((and (consp value) (eq (car value) 'minmax))
(unless (and (consp (cdr value))
(consp (cddr value))
(null (cdddr value)))
(error "ebox-grid: invalid minmax track %S" value))
(let ((minimum (ebox-grid--normalize-track (cadr value) axis property))
(maximum (ebox-grid--normalize-track (caddr value) axis property)))
(unless (memq (plist-get minimum :kind)
'(fixed auto min-content max-content))
(error "ebox-grid: minmax minimum must be fixed or auto: %S" value))
(unless (memq (plist-get maximum :kind)
'(fixed auto min-content max-content fr))
(error "ebox-grid: minmax maximum cannot be nested: %S" value))
(list :kind 'minmax :min minimum :max maximum)))
((ebox-size-value-p value nil t)
(list :kind 'fixed :size
(ebox-size-validate-for-axis
value property (if (eq axis 'columns) 'inline 'block))))
(t (error "ebox-grid: unsupported %S track: %S" axis value))))
(defun ebox-grid--normalize-tracks (value axis &optional property)
"Normalize track template VALUE along AXIS for optional PROPERTY."
(mapcar (lambda (track) (ebox-grid--normalize-track track axis property))
(ebox-grid--expand-repeat (ebox-grid--track-list value))))
(defun ebox-grid--gap-pair (props)
"Return normalized (ROW-GAP . COLUMN-GAP) from grid PROPS."
(let* ((pair (ebox--flex-gap-pair (plist-get props :gap)))
(row (cond
((plist-member props :row-gap) (plist-get props :row-gap))
(t (car pair))))
(column (cond
((plist-member props :column-gap)
(plist-get props :column-gap))
(t (cdr pair)))))
(cons (ebox--flex-row-gap-value row)
(ebox--flex-column-gap-value column))))
(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 property)))
(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."
(dolist (property '(:grid-template-columns :grid-template-rows))
(when-let* ((value (plist-get props property)))
(unless (and (proper-list-p value)
value
(not (ebox-grid--track-form-p value)))
(error "Ebox GridConfig %S requires a nonempty track list: %S"
property value))))
(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) 'flex-start
ebox--grid-content-alignment-values :justify-content)
:align-content
(ebox-grid--enum-value
(plist-get props :align-content) 'stretch
ebox--grid-content-alignment-values :align-content))))
(defconst ebox--grid-default-config-props
(ebox-grid--normalize-config-props nil)
"Computed default property set for GridConfig.")
(defun ebox-grid--canonical-atomic-track-p (track allowed-kinds &optional axis)
"Return non-nil when TRACK has an ALLOWED-KINDS kind and valid AXIS units."
(and (proper-list-p track)
(memq (plist-get track :kind) allowed-kinds)
(pcase (plist-get track :kind)
('auto (equal track '(:kind auto :factor 0)))
('min-content (equal track '(:kind min-content)))
('max-content (equal track '(:kind max-content)))
('fixed
(let ((size (plist-get track :size)))
(and (ebox-size-value-for-axis-p
size (pcase axis ('columns 'inline) ('rows 'block) (_ 'length)))
(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 &optional axis)
"Return non-nil when TRACK is canonical, including optional AXIS units."
(or (ebox-grid--canonical-atomic-track-p
track '(fixed auto min-content max-content fr) axis)
(and (proper-list-p track)
(eq (plist-get track :kind) 'minmax)
(ebox-grid--canonical-atomic-track-p
(plist-get track :min) '(fixed auto min-content max-content) axis)
(ebox-grid--canonical-atomic-track-p
(plist-get track :max)
'(fixed auto min-content max-content fr) axis)
(equal track
(list :kind 'minmax
:min (plist-get track :min)
:max (plist-get track :max))))))
(defun ebox-grid-layout-config-props-p (props)
"Return non-nil when PROPS are canonical validated GridConfig data."
(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 (lambda (track) (ebox-grid--canonical-track-p track 'columns))
(plist-get props :grid-template-columns))
(cl-every (lambda (track) (ebox-grid--canonical-track-p track 'rows))
(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 'columns))
(or (null auto-row)
(ebox-grid--canonical-track-p auto-row 'rows))))
(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 (ebox-size-value-for-axis-p row-gap 'block)
(ebox-size-value-for-axis-p column-gap 'inline)))
(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
(defun ebox-grid-layout-create (&rest plist)
"Return a typed GridConfig from canonical evaluated PLIST.
Author `:gap' sugar is expanded before this programmatic port. Removed
grid-gap aliases are rejected at the author schema boundary."
(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)
"Return non-nil when PROPS contains visible box behavior."
(cl-loop for (key _value) on props by #'cddr
thereis (not (memq key (append ebox--grid-layout-prop-keys
'(:width :height :box-sizing)
ebox--grid-item-prop-keys)))))
(defun ebox-grid--wrapper (props)
"Create a visual wrapper for grid PROPS, or return nil."
(when (ebox-grid--visual-props-p props)
(when (plist-member props :content)
(error "ebox-grid: grid cannot combine :content with child nodes"))
(apply #'ebox-node-factory--create-from-properties
(append (ebox--preformatted-box-props props)
(list :content "")))))
(defun ebox-grid--placement-part (value default)
"Return (START SPAN) parsed from placement VALUE and DEFAULT."
(cond
((null value) (list default 1))
((numberp value)
(if (> value 0)
(list value 1)
(error "ebox-grid: placement starts at one: %S" value)))
((and (proper-list-p value)
(eq (car value) :span)
(integerp (cadr value))
(null (cddr value)))
(list default (cadr value)))
((and (consp value) (numberp (car value)))
(let ((start (car value))
(tail (cdr value)))
(unless (> start 0)
(error "ebox-grid: placement starts at one: %S" value))
(cond
((null tail) (list start 1))
((and (eq (car tail) :span) (numberp (cadr tail)))
(list start (cadr tail)))
((numberp (car tail))
(list start (- (car tail) start)))
(t (error "ebox-grid: invalid placement %S" value)))))
(t (error "ebox-grid: invalid placement %S" value))))
(defun ebox-grid--placement (child)
"Return CHILD placement as (ROW COL ROW-SPAN COL-SPAN)."
(let* ((row (ebox-grid--placement-part (plist-get child :grid-row) 0))
(column (ebox-grid--placement-part
(plist-get child :grid-column) 0))
(row-span (cadr row))
(column-span (cadr column)))
(dolist (part (list row column))
(when (or (not (integerp (car part))) (< (car part) 0)
(not (integerp (cadr part))) (< (cadr part) 1))
(error "ebox-grid: invalid placement %S" part)))
(unless (and (integerp row-span) (> row-span 0)
(integerp column-span) (> column-span 0))
(error "ebox-grid: grid spans must be positive integers"))
(list (car row) (car column) row-span column-span)))
(defun ebox-grid--occupancy (rows columns)
"Return an empty ROWS by COLUMNS occupancy matrix."
(let ((matrix (make-vector rows nil)))
(cl-loop for row from 0 below rows
do (aset matrix row (make-vector columns nil))
finally return matrix)))
(defun ebox-grid--grow (matrix rows columns)
"Grow MATRIX to at least ROWS and COLUMNS and return it."
(let* ((result matrix)
(existing-columns
(if (> (length result) 0)
(length (aref result 0))
0))
(target-columns (max columns existing-columns)))
(when (> columns 0)
(dotimes (row (length result))
(when (< (length (aref result row)) target-columns)
(setf (aref result row)
(vconcat (aref result row)
(make-vector
(- target-columns (length (aref result row)))
nil))))))
(when (> rows (length result))
(setq result
(vconcat result
(ebox-grid--occupancy (- rows (length result))
target-columns))))
result))
(defun ebox-grid--free-p (matrix row column row-span column-span)
"Return non-nil when a placement rectangle is free in MATRIX."
(and (> row 0) (> column 0)
(cl-loop for r from (1- row) below (+ (1- row) row-span)
always (cl-loop for c from (1- column)
below (+ (1- column) column-span)
always (not (aref (aref matrix r) c))))))
(defun ebox-grid--mark (matrix entry row column row-span column-span)
"Mark ENTRY in placement rectangle MATRIX."
(cl-loop for r from (1- row) below (+ (1- row) row-span) do
(cl-loop for c from (1- column)
below (+ (1- column) column-span) do
(setf (aref (aref matrix r) c) entry))))
(defun ebox-grid--find-position (matrix placement flow)
"Find a free position for PLACEMENT in MATRIX using FLOW."
(let ((row (nth 0 placement))
(column (nth 1 placement))
(row-span (nth 2 placement))
(column-span (nth 3 placement)))
(cl-labels
((free-at-p (r c)
(and (<= (+ r row-span -1) (length matrix))
(<= (+ c column-span -1)
(length (aref matrix 0)))
(ebox-grid--free-p matrix r c row-span column-span)))
(scan-row (r start)
(cl-loop for c from start to (length (aref matrix 0))
when (free-at-p r c) return (list r c)))
(scan-column (c start)
(cl-loop for r from start to (length matrix)
when (free-at-p r c) return (list r c)))
(scan-flow ()
(or (if (eq flow 'column)
(cl-loop for c from 1 to (length (aref matrix 0))
thereis (scan-column c 1))
(cl-loop for r from 1 to (length matrix)
thereis (scan-row r 1)))
(if (eq flow 'column)
(list 1 (1+ (length (aref matrix 0))))
(list (1+ (length matrix)) 1)))))
(cond
((and (> row 0) (> column 0)) (list row column))
((> row 0)
(or (scan-row row (if (> column 0) column 1))
(list (1+ (length matrix)) 1)))
((> column 0)
(or (scan-column column 1)
(list (1+ (length matrix)) 1)))
(t (scan-flow))))))
(defun ebox-grid--place-children (children columns rows flow)
"Place CHILDREN and return (ENTRIES MATRIX ROWS COLUMNS)."
(let ((matrix (ebox-grid--occupancy rows columns))
entries)
(dolist (child children)
(let* ((placement (ebox-grid--placement child))
(row-span (nth 2 placement))
(column-span (nth 3 placement))
(row (nth 0 placement))
(column (nth 1 placement))
(explicit-p (and (> row 0) (> column 0)))
(candidate-row (if (> row 0) row 1))
(candidate-column (if (> column 0) column 1)))
(setq matrix (ebox-grid--grow matrix
(+ candidate-row row-span -1)
(+ candidate-column column-span -1)))
(if (ebox-grid--free-p matrix candidate-row candidate-column
row-span column-span)
(setq row candidate-row column candidate-column)
(when explicit-p
(error "ebox-grid: overlapping explicit placement at row %S column %S"
row column))
(let ((position (ebox-grid--find-position
matrix (list row column row-span column-span) flow)))
(setq row (car position) column (cadr position))
(setq matrix (ebox-grid--grow matrix (+ row row-span -1)
(+ column column-span -1)))))
(let ((entry (list :node child :row row :column column
:row-span row-span :column-span column-span)))
(ebox-grid--mark matrix entry row column row-span column-span)
(push entry entries))))
(list (nreverse entries) matrix (length matrix)
(if (> (length matrix) 0) (length (aref matrix 0)) columns))))
(defun ebox-grid--string-min-content-pixel (string)
"Return STRING's widest unbreakable run in pixels."
(let ((best 0))
(dolist (line (ebox-string-lines string) best)
(setq best (max best (ebox--line-min-content-pixel line))))))
(defun ebox-grid--entry-intrinsic-size (entry rendered axis mode)
"Return ENTRY's intrinsic size from RENDERED for AXIS and MODE."
(let* ((node (plist-get entry :node))
(string (gethash node rendered)))
(if (eq mode 'auto-min)
(let* ((horizontal (eq axis 'columns))
(span (plist-get entry (if horizontal :column-span :row-span))))
(if (ebox-box-node-p node)
(if (and (> (or span 1) 1)
(not (ebox--flex-explicit-min-main-p
node (if horizontal 'row 'column))))
0
(/ (ebox--flex-box-min-main
node (or string "") (if horizontal 'row 'column))
(float (or span 1))))
(if horizontal
(or (ebox--rendered-intrinsic-size string :width)
(ebox-grid--string-min-content-pixel (or string "")))
(or (ebox--rendered-intrinsic-size string :height)
(ebox-string-height (or string ""))))))
(if (eq axis 'rows)
(or (ebox--rendered-intrinsic-size string :height)
(ebox-string-height string))
(pcase mode
('min-content
(if (ebox-box-node-p node)
(+ (ebox--side-pixel node) (ebox--content-min-pixel node))
(or (ebox--rendered-intrinsic-size string :width)
(ebox-grid--string-min-content-pixel string))))
('max-content
(if (ebox-box-node-p node)
(+ (ebox--side-pixel node) (ebox--content-max-pixel node))
(or (ebox--rendered-intrinsic-size string :width)
(ebox--string-max-pixel-width string))))
(_ (if (and (ebox-box-node-p node)
(zerop (ebox-string-height string)))
(ebox--flex-source-width node string)
(or (ebox--rendered-intrinsic-size string :width)
(ebox--string-pixel-width string)))))))))
(defun ebox-grid--track-intrinsic (track axis entries rendered key slot-index)
"Return intrinsic size for TRACK from RENDERED grid ENTRIES."
(pcase (plist-get track :kind)
('fixed (plist-get track :size))
('minmax
(ebox-grid--track-intrinsic (plist-get track :min)
axis entries rendered key slot-index))
(_ (let ((position (1+ slot-index))
(mode (if (eq (plist-get track :kind) 'fr)
'auto-min (plist-get track :kind)))
(best 0))
(dolist (entry entries best)
(when (and (<= (plist-get entry key) position)
(< position
(+ (plist-get entry key)
(plist-get entry
(if (eq key :column)
:column-span
:row-span)))))
(let ((value
(ebox-grid--entry-intrinsic-size
entry rendered axis mode)))
(setq best (max best value)))))))))
(defun ebox-grid--sum (values)
"Return the numeric sum of VALUES."
(apply #'+ (or values '(0))))
(defun ebox-grid--track-max (track)
"Return TRACK's fixed maximum, or nil when it is unbounded."
(pcase (plist-get track :kind)
('fixed (plist-get track :size))
('minmax (ebox-grid--track-max (plist-get track :max)))
(_ nil)))
(defun ebox-grid--track-flex-factor (track)
"Return TRACK's flexible maximum factor, or zero when it is fixed.
`minmax' tracks commonly use a zero minimum with a fractional maximum for
responsive layouts. Treat that maximum as a real flexible track instead of
letting the grid fall back to each child's intrinsic width."
(pcase (plist-get track :kind)
('fr (plist-get track :factor))
('minmax (ebox-grid--track-flex-factor (plist-get track :max)))
(_ 0)))
(defun ebox-grid--clamp-track-size (track size)
"Clamp SIZE to TRACK's fixed maximum when one exists."
(if-let* ((maximum (ebox-grid--track-max track)))
(min size maximum)
size))
(defun ebox-grid--used-track (track axis available &optional box)
"Resolve TRACK for AXIS and AVAILABLE using BOX's font metrics.
An indefinite percentage track participates as auto. Canonical source
descriptors remain unresolved, so subsequent layouts can use a new context."
(pcase (plist-get track :kind)
('fixed
(let* ((horizontal (eq axis 'columns))
(line-height (ebox--size-line-height box))
(ebox-viewport-width (if horizontal available ebox-viewport-width))
(ebox--css-containing-height
(if horizontal ebox--css-containing-height
(and available (* available line-height))))
(value (plist-get track :size))
(pixels (if (numberp value) value
(ebox--size-pixels value box
(if horizontal 'width 'height)))))
(if pixels
(list :kind 'fixed :size
(max 0 (if (or horizontal (numberp value)) pixels
(/ pixels (float line-height)))))
'(:kind auto :factor 0))))
('minmax
(let ((minimum (ebox-grid--used-track
(plist-get track :min) axis available box))
(maximum (ebox-grid--used-track
(plist-get track :max) axis available box)))
;; CSS ignores a maximum below the minimum once both are definite.
(when (and (eq (plist-get minimum :kind) 'fixed)
(eq (plist-get maximum :kind) 'fixed)
(< (plist-get maximum :size) (plist-get minimum :size)))
(setq maximum minimum))
(list :kind 'minmax :min minimum :max maximum)))
(_ track)))
(defun ebox-grid--resolve-sizes
(tracks count axis entries rendered gap available &optional auto-track box)
"Resolve TRACKS to COUNT sizes along AXIS within AVAILABLE space.
ENTRIES and RENDERED supply intrinsic content, GAP is the used gap, BOX
supplies font metrics, and AUTO-TRACK describes implicit tracks."
(let* ((auto-track (ebox-grid--used-track
(or auto-track '(:kind auto :factor 0)) axis available box))
(tracks (mapcar (lambda (track)
(ebox-grid--used-track track axis available box))
tracks))
(tracks (append tracks
(make-list (max 0 (- count (length tracks)))
auto-track)))
(count (length tracks))
(sizes (cl-loop for track in tracks
for index from 0
collect (ebox-grid--clamp-track-size
track
(ebox-grid--track-intrinsic
track axis entries rendered
(if (eq axis 'columns) :column :row)
index))))
(flexible (cl-loop for track in tracks for index from 0
when (> (ebox-grid--track-flex-factor track) 0)
collect index)))
(when (and (null available) flexible)
(let ((fraction
(cl-loop for index in flexible
for factor = (ebox-grid--track-flex-factor (nth index tracks))
maximize
(/ (float
(max (nth index sizes)
(ebox-grid--track-intrinsic
'(:kind max-content) axis entries rendered
(if (eq axis 'columns) :column :row) index)))
(max 1 factor)))))
(dolist (index flexible)
(setf (nth index sizes)
(* fraction (ebox-grid--track-flex-factor (nth index tracks)))))))
(when (and available flexible)
(let ((remaining
(- available (* gap (max 0 (1- count)))
(cl-loop for size in sizes for index from 0
unless (memq index flexible) sum size))))
(while flexible
(let* ((factor-total
(cl-loop for index in flexible
sum (ebox-grid--track-flex-factor (nth index tracks))))
(fraction (/ (float (max 0 remaining)) (max 1 factor-total)))
(frozen
(cl-remove-if-not
(lambda (index)
(> (nth index sizes)
(* fraction (ebox-grid--track-flex-factor
(nth index tracks)))))
flexible)))
(if frozen
(dolist (index frozen)
(setq remaining (- remaining (nth index sizes))
flexible (delq index flexible)))
(dolist (index flexible)
(setf (nth index sizes)
(* fraction (ebox-grid--track-flex-factor (nth index tracks)))))
(setq flexible nil))))))
(ebox-size-quantize sizes)))
(defun ebox-grid--content-independent-columns-p (tracks count available &optional children)
"Return non-nil when TRACKS determine COUNT widths without cell content.
Require a definite AVAILABLE width and explicit fixed/fr tracks. CHILDREN
must have zero automatic minima when fractional tracks are present."
(and (numberp available) (> available 0)
(>= (length tracks) count)
(cl-every (lambda (track)
(memq (plist-get track :kind) '(fixed fr)))
tracks)
(or (cl-every (lambda (track) (eq (plist-get track :kind) 'fixed)) tracks)
(cl-every
(lambda (child)
(and (ebox-box-node-p child)
(or (and (memq (ebox-get child :min-width) '(nil auto))
(memq (ebox-get child :overflow) '(auto scroll hidden)))
(equal (ebox-get child :min-width) 0)
(equal (ebox-get child :min-width) '(px 0)))))
children))))
(defun ebox-grid--distribute (amount count)
"Return COUNT integer shares that sum to AMOUNT."
(let ((base (if (> count 0) (/ amount count) 0))
(remainder (if (> count 0) (% amount count) 0)))
(cl-loop for index from 0 below count
collect (+ base (if (< index remainder) 1 0)))))
(defun ebox-grid--content-layout (sizes gap available alignment)
"Return track SIZES and content spacing for AVAILABLE space."
(let* ((natural (+ (ebox-grid--sum sizes)
(* gap (max 0 (1- (length sizes))))))
(target (max natural (or available natural)))
(leftover (- target natural)))
(if (and (> leftover 0) (memq alignment '(stretch normal)))
(let ((extra (ebox-grid--distribute leftover (length sizes))))
(list :sizes (cl-mapcar #'+ sizes extra)
:leading 0 :between gap :trailing 0
:target target))
(let ((spacing (ebox--flex-spacing
(or alignment 'start) leftover (length sizes) gap)))
(list :sizes sizes
:leading (nth 0 spacing)
:between (nth 1 spacing)
:trailing (nth 2 spacing)
:target target)))))
(defun ebox-grid--final-content-layout (sizes gap-value axis available alignment box)
"Lay out SIZES with GAP-VALUE on AXIS in AVAILABLE space for BOX.
ALIGNMENT positions tracks. Cyclic percentage gaps contribute zero during
intrinsic sizing, then resolve against that intrinsic size for final layout.
The resulting gap may overflow the unchanged intrinsic container extent."
(let* ((gap (ebox--layout-used-gap gap-value axis available box))
(cyclic (and (null available)
(ebox-size-value-p gap-value nil t)
(memq 'percent (ebox-size-dependencies gap-value))))
(intrinsic (+ (ebox-grid--sum sizes)
(* gap (max 0 (1- (length sizes))))))
(used-gap (if cyclic
(ebox--layout-used-gap gap-value axis intrinsic box)
gap))
(layout (ebox-grid--content-layout sizes used-gap available alignment)))
(when cyclic
(plist-put layout :target intrinsic)
(plist-put layout :intrinsic-size intrinsic))
layout))
(defun ebox-grid--entry-size (entry sizes gap)
"Return the target pixel/line size for ENTRY across SIZES."
(let* ((start (1- (plist-get entry :column)))
(end (min (length sizes)
(+ start (plist-get entry :column-span))))
(base (ebox-grid--sum (seq-subseq sizes start end)))
(extra (* gap (max 0 (1- (plist-get entry :column-span))))))
(+ base extra)))
(defun ebox-grid--auto-width-node-p (node)
"Return non-nil when NODE derives its width from the active viewport."
(let ((width (plist-get node :width)))
;; `stretch' is a containing-block width. Treating it
;; as intrinsic leaves fractional Grid tracks empty and makes composite
;; cards hug their natural width instead of filling the assigned track.
(or (null width) (memq width '(auto stretch)))))
(defun ebox-grid--entry-source (entry rendered width props)
"Return ENTRY's content rendered within its containing WIDTH.
Stretching is the default grid item alignment. Re-render an auto-sized
child at its assigned width for that alignment, while preserving its natural
size for start/center/end alignment unless it would overflow its track."
(let* ((node (plist-get entry :node))
(source (gethash node rendered))
(auto-width-p (ebox-grid--auto-width-node-p node))
(justify (or (plist-get props :justify-items) 'stretch))
(stretch-p (memq justify '(normal stretch))))
(unless (or (and auto-width-p stretch-p) (stringp source))
(error "ebox-grid: Grid item requires missing intrinsic output"))
(if (or (and auto-width-p
(or stretch-p (> (ebox--string-max-pixel-width source) width)))
(and (not auto-width-p)
(ebox--viewport-dependent-subtree-p node)))
(let ((ebox-viewport-width width)
(ebox--render-root-parent-kind 'grid))
(ebox--render-with-cache node))
source)))
(defun ebox-grid--row-entry-size (entry sizes gap)
"Return the target line size for ENTRY across row SIZES."
(let* ((start (1- (plist-get entry :row)))
(end (min (length sizes)
(+ start (plist-get entry :row-span))))
(base (ebox-grid--sum (seq-subseq sizes start end)))
(extra (* gap (max 0 (1- (plist-get entry :row-span))))))
(+ base extra)))
(defun ebox-grid--align (string width height justify align)
"Align STRING to WIDTH and HEIGHT using JUSTIFY and ALIGN."
(let ((result (ebox--lines-justify
(if (or (null string) (string-empty-p string))
(ebox-grid--blank width height)
string)
width
(pcase justify
((or 'end 'right) 'right)
('center 'center)
(_ 'left)))))
(ebox--lines-align-vertical
result height
(pcase align
((or 'end 'flex-end 'bottom) 'bottom)
('center 'center)
(_ 'top)))))
(defun ebox-grid--entry-string (entry rendered widths heights col-gap row-gap props)
"Size and align ENTRY's width-sized RENDERED output within its rectangle.
WIDTHS and HEIGHTS hold track sizes separated by COL-GAP and ROW-GAP.
PROPS provides the container's item alignment defaults.
An auto-height Box stretches its own geometry and paint to the assigned rows.
Its normal Box renderer still owns margins, padding, and min/max constraints."
(let* ((width (ebox-grid--entry-size entry widths col-gap))
(height (ebox-grid--row-entry-size entry heights row-gap))
(node (plist-get entry :node))
(source (gethash node rendered))
(justify (or (plist-get props :justify-items) 'stretch))
(self (plist-get node :align-self))
(align (if (memq self '(nil auto))
(or (plist-get props :align-items) 'stretch)
self))
(stretch-p (and (memq align '(normal stretch))
(ebox-box-node-p node)
(memq (ebox-get node :height) '(nil auto)))))
(when (or (and stretch-p (/= height (ebox-string-height source)))
(ebox--viewport-height-dependent-subtree-p node))
(let* ((ebox-viewport-width width)
(ebox--css-containing-height
(* height (ebox--size-line-height)))
(ebox--render-root-parent-kind 'grid)
;; Height assignment must not stretch the other axis when its
;; already sized output fits the track under start/center/end.
(ebox--inline-auto-width-intrinsic-p
(and (not (memq justify '(normal stretch)))
(< (ebox--string-max-pixel-width source) width)))
(sized (if stretch-p (copy-sequence node) node)))
(when stretch-p
(plist-put sized :region-id (ebox--ensure-region-id node))
(plist-put sized :height 'stretch))
(setq source (ebox--render-with-cache sized))
(when stretch-p
(puthash (ebox-get node :region-id) node ebox--region-box-table))))
(ebox-grid--align source width height justify align)))
(defun ebox-grid--sized-rendered-entries (entries rendered widths gap props)
"Render ENTRIES at their resolved column widths for later row sizing."
(let ((sized (make-hash-table :test 'eq)))
(dolist (entry entries)
(let* ((node (plist-get entry :node))
(width (ebox-grid--entry-size entry widths gap)))
(puthash node
(ebox-grid--entry-source entry rendered width props)
sized)))
sized))
(defun ebox-grid--entry-at (matrix row column)
"Return entry occupying ROW and COLUMN in MATRIX."
(and (< row (length matrix))
(< column (length (aref matrix row)))
(aref (aref matrix row) column)))
(defun ebox-grid--entry-start-p (entry row column)
"Return non-nil when ENTRY's slice starts at zero-based ROW and COLUMN."
(let ((start (1- (plist-get entry :row))))
(and (<= start row)
(< row (+ start (plist-get entry :row-span)))
(= column (1- (plist-get entry :column))))))
(defun ebox-grid--blank (width height)
"Return a blank grid area of WIDTH pixels and HEIGHT lines."
(or (ebox--pixel-blank width height)
(ebox-lines-join (make-list (max 0 height) ""))))
(defun ebox-grid--slice (string offset height)
"Return HEIGHT lines from STRING beginning at OFFSET."
(ebox-lines-join
(append (seq-take (nthcdr offset (ebox-string-lines string)) height)
(make-list (max 0 (- height
(length (seq-take
(nthcdr offset (ebox-string-lines string))
height))))
""))))
(defun ebox-grid--render (entries matrix column-layout row-layout props rendered)
"Render placed grid ENTRIES from MATRIX and resolved track layouts."
(let* ((widths (plist-get column-layout :sizes))
(heights (plist-get row-layout :sizes))
(col-gap (plist-get column-layout :between))
(row-gap (plist-get row-layout :between))
(total-width (plist-get column-layout :target))
(strings (make-hash-table :test 'eq))
result)
(dolist (entry entries)
(puthash entry
(ebox-grid--entry-string entry rendered widths heights
col-gap row-gap props)
strings))
(dotimes (row (length heights))
(let* ((row-height (nth row heights))
(gap-after (if (< row (1- (length heights))) row-gap 0))
(height (+ row-height gap-after))
(parts nil)
(column 0))
(when (and (> height 0) (null widths))
(push (ebox-grid--blank total-width height) parts))
(while (and (> height 0) (< column (length widths)))
(let ((entry (ebox-grid--entry-at matrix row column)))
(if (and entry (ebox-grid--entry-start-p entry row column))
(let* ((string (gethash entry strings))
(start (1- (plist-get entry :row)))
(offset (+ (ebox-grid--sum
(seq-subseq heights start row))
(* row-gap (- row start))))
(continues-p
(> (+ start (plist-get entry :row-span)) (1+ row)))
(slice-height (if continues-p height row-height))
(piece (and (> slice-height 0)
(ebox-grid--slice
string offset slice-height))))
;; A spanning cell owns the intervening gap lines too.
;; Other cells leave that same area blank after clipping
;; their output to the allocated row height.
(when (and (> gap-after 0) (not continues-p))
(let ((gap (ebox-grid--blank
(ebox-grid--entry-size entry widths col-gap)
gap-after)))
(setq piece (if piece
(ebox-lines-join (list piece gap))
gap))))
(push piece parts)
(setq column (+ column (plist-get entry :column-span))))
(let ((width (nth column widths)))
(push (ebox-grid--blank width height) parts)
(setq column (1+ column))))
(when (and (< column (length widths)) (> col-gap 0))
(push (ebox-pixel-space col-gap) parts))))
(when parts
(setq result
(append result
(list (apply #'ebox--lines-concat-horizontal
(append
(when (> (plist-get column-layout :leading) 0)
(list (ebox-pixel-space
(plist-get column-layout :leading))))
(nreverse parts)
(when (> (plist-get column-layout :trailing) 0)
(list (ebox-pixel-space
(plist-get column-layout :trailing))))))))))
))
(ebox-lines-join
(append
(when (> (plist-get row-layout :leading) 0)
(list (ebox-grid--blank total-width
(plist-get row-layout :leading))))
result
(when (> (plist-get row-layout :trailing) 0)
(list (ebox-grid--blank total-width
(plist-get row-layout :trailing))))))))
(defun ebox--render-grid-box (node rendered)
"Render grid NODE's optional visual wrapper around RENDERED."
(if-let* ((box (plist-get node :box)))
(let ((copy (copy-sequence box))
(region-id (ebox--ensure-region-id box)))
(plist-put copy :region-id region-id)
(plist-put copy :content rendered)
(plist-put copy :ebox-content-node nil)
(plist-put copy :ebox-content-width-exact-p t)
(prog1 (ebox--render-node copy)
(puthash region-id box ebox--region-box-table)))
rendered))
(defun ebox-grid--legacy-constraints (props wrapper)
"Return private GridConstraints from legacy PROPS and WRAPPER."
(let ((width (ebox--nonnegative-horizontal-size-pixels
(plist-get props :width)
(ebox--viewport-pixel-width nil)))
(height (ebox--flex-line-value (plist-get props :height) nil)))
(when wrapper
(setq width (ebox--box-sizing-content-pixel wrapper width)))
(list :width width :height height)))
(defun ebox-grid--box-constraints (box)
"Project BOX content dimensions to private GridConstraints."
(list :width (ebox--wrapper-content-viewport-pixel box)
:height (ebox--resolve-size-content-height
box (ebox-get box :height) nil)))
(defun ebox--render-grid-children (config constraints children &optional box)
"Render Grid CONFIG under CONSTRAINTS over CHILDREN using BOX font metrics."
(let* ((props (ebox--layout-used-gaps
config (plist-get constraints :width)
(plist-get constraints :height) box))
(columns (plist-get props :grid-template-columns))
(rows (plist-get props :grid-template-rows))
(auto-columns (plist-get props :grid-auto-columns))
(auto-rows (plist-get props :grid-auto-rows))
(gaps (cons (plist-get props :row-gap)
(plist-get props :column-gap)))
(flow (or (plist-get props :grid-auto-flow) 'row))
(placed (ebox-grid--place-children children
(if children (max 1 (length columns))
(length columns))
(if children (max 1 (length rows))
(length rows)) flow))
(entries (nth 0 placed))
(matrix (nth 1 placed))
(row-count (nth 2 placed))
(column-count (nth 3 placed))
(available (when-let* ((width (plist-get constraints :width)))
(floor width)))
(height (when-let* ((height (plist-get constraints :height)))
(floor height)))
(ebox--css-containing-height
(and height (* height (ebox--size-line-height box))))
(content-independent-p
(and (ebox-grid--content-independent-columns-p
columns column-count available children)
(memq (or (plist-get props :justify-items) 'stretch)
'(normal stretch))))
(rendered (make-hash-table :test 'eq)))
(unless (memq flow '(row column))
(error "ebox-grid: :grid-auto-flow must be `row' or `column'"))
(dolist (entry entries)
(let ((child (plist-get entry :node)))
;; Neither column sizing nor unconditional stretch consumes this
;; natural output. The final Box render still resolves its own
;; min/max constraints before its output is used to size rows.
(unless (and content-independent-p
(ebox-box-node-p child)
(ebox-grid--auto-width-node-p child))
(let ((ebox-viewport-width nil)
(ebox--intrinsic-layout-measurement t))
(puthash child (ebox--render-with-cache child) rendered)))))
(let* ((widths (ebox-grid--resolve-sizes columns column-count 'columns
entries rendered (cdr gaps)
available auto-columns box))
(column-layout
(ebox-grid--final-content-layout
widths (plist-get config :column-gap) 'width available
(or (plist-get props :justify-content) 'start) box))
(sized-rendered
(ebox-grid--sized-rendered-entries
entries rendered (plist-get column-layout :sizes)
(plist-get column-layout :between) props))
(heights (ebox-grid--resolve-sizes rows row-count 'rows
entries sized-rendered (car gaps)
height auto-rows box))
(row-layout
(ebox-grid--final-content-layout
heights (plist-get config :row-gap) 'height height
(or (plist-get props :align-content) 'start) box))
(body (ebox-grid--render entries matrix column-layout row-layout
props sized-rendered)))
(ebox--record-rendered-intrinsic-size
body (or (plist-get column-layout :intrinsic-size)
(and (string-empty-p body)
(plist-get column-layout :target)))
(plist-get row-layout :intrinsic-size)))))
(defun ebox--render-grid-box-children (box config children)
"Render canonical BOX Grid CONFIG over flat CHILDREN."
(ebox--render-grid-children
config (ebox-grid--box-constraints box) children box))
(defun ebox--render-grid (node)
"Render legacy GRID NODE to a propertized string."
(let* ((props (plist-get node :raw-props))
(wrapper (plist-get node :box)))
(ebox--render-grid-box
node
(ebox--render-grid-children
(or (plist-get node :ebox-grid-config)
(error "Ebox Grid node lacks canonical config"))
(ebox-grid--legacy-constraints props wrapper)
(ebox-tree-layout-children node)))))
(provide 'ebox-grid)
;;; ebox-grid.el ends here