外观
DRAFT ETML: Flex 布局算法及 Elisp 实现
约 3980 字大约 13 分钟
elispetml
2025-08-31
这篇文章介绍了 CSS Flex 弹性盒子布局的算法,以及 ETML 中的 emacs-lisp 的实现。理论上,读者可以根据文章中的算法使用任何语言来实现文本块的 flex 布局,并不局限于 emacs-lisp。
CSS Flex 简介
Flex 布局算法
- 根据 self 渲染为原始字符串
- 根据 order 属性,重新排列子项的顺序
根据 basis 计算每个子项在主轴方向的初始长度 获取 flex 容器宽度 计算所有子项的初始长度总和 如果
- 根据 grow, shrink,计算子项经过拉伸后压缩后的宽度
- ....
Flex 布局模型
etml-flex
模型继承自 etml-block
,因此除了自身定义的属性,也可以使用 etml-block
的所有属性。
布局模型 elisp 代码
etml-flex.el
etml-block.el
(defclass etml-block ()
((uuid :initarg :uuid :initform (org-id-uuid))
(content :initarg :content :initform "" :type string)
(width :initarg :width :initform nil
:documentation "content pixel width or char number.
If it's a pixel, it should be a cons-cell (<n-pixel>); if it's
the number of char, it should be a <number>.")
(min-width :initarg :min-width :initform nil
:documentation "min width of content, format is
the same with width.")
(max-width :initarg :max-width :initform nil
:documentation "max width of content, format is
the same with width.")
(justify :initarg :justify :initform 'left)
(height :initarg :height :initform nil
:documentation "content height")
(min-height :initarg :min-height :initform 1
:documentation "content min height")
(max-height :initarg :max-height :initform nil
:documentation "content max height")
(align :initarg :align :initform 'top)
(bgcolor :initarg :bgcolor :initform nil)
(border :initarg :border :initform nil)
(margin :initarg :margin :initform nil)
(padding :initarg :padding :initform nil)
(nowrap :initarg :nowrap :initform nil :type symbol
:documentation "表示水平方向是否不换行,默认换行")
(overflow
:initarg :overflow :initform 'scroll
:documentation "溢出时如何显示,会被解析为:(x-overflow . y-overflow)。
1. 当 nowrap=t 且内容长度超过 width 时,根据 overflow-x 处理溢出部分;
2. 当 height > 文本高度时,根据 overflow-y 处理溢出的行。
支持以下的值: visible(内容溢出到容器外), hidden(截断溢出部分),\
scroll(默认:内容溢出时支持滚动)。")
(scroll-offset-x
:initarg :scroll-offset-x :initform 0
:documentation "当有溢出时的水平方向的偏移量")
(scroll-offset-y
:initarg :scroll-offset-y :initform 0
:documentation "当有溢出时的垂直方向的偏移量")
(scroll-bar-pixel :initarg :scroll-bar-pixel
:initform 2)
(scroll-bar-color :initarg :scroll-bar-color
:initform '("#111" . "#fff"))
(scroll-bar-direction :initarg :scroll-bar-direction
:initform 'right)
(scroll-bar-gap
:initarg :scroll-bar-gap
:initform 1
:documentation
"The gap pixel between scroll bar and border \
when has border. If scroll-bar-full is non-nil,\
set gaps at both.")
(scroll-bar-full
:initarg :scroll-bar-full
:initform nil
:documentation
"在有border的时候生效,确定是否用两边border包裹的 scroll bar.")))
Flex 子项模型
etml-flex.el
(defclass etml-flex-item (etml-block)
((self :initarg :self :type etml-block
:documentation "item 本身。")
(order :initarg :order :initform 0 :type integer
:documentation "item 在 flex 容器中顺序。")
(basis :initarg :basis :initform 'auto :type symbol
:documentation "item 在分配空间前的初始尺寸。")
(grow :initarg :grow :initform 0 :type integer
:documentation "item 在容器有剩余空间时的增长系数。")
(shrink :initarg :shrink :initform 1 :type integer
:documentation "item 在容器空间不足时的缩小系数。")
(align :initarg :align :initform 'auto :type symbol
:documentation "item 在交叉轴的对齐方式,覆盖容器的 items-align 属性。")))
self
定义 item 本身,它是一个 etml-block
对象。
order
定义 item 在 flex 容器中顺序。
具体实现
容器中的 items 会按照 order 值的大小进行排列展示,对于相同的 order 值,按照其在容器中的原始顺序进行排列。
basis
定义 item 在主轴方向上的初始长度。支持设置为如下的值:
200
: 设置一个具体的像素值auto
(默认): 根据项目内容或显式设置的 width/height 决定初始尺寸;如果设置了 width(水平主轴) 或 height(垂直主轴),则使用该值;未设置尺寸时,自动根据内容大小计算初始尺寸。max-content
: 内容不换行时的最大宽度,宽度由内容中最长的单词/元素决定。不受容器宽度限制。min-content
: 设置为内容能换行时的最小宽度,宽度由内容中最长的不可断元素(如图片、长单词)决定。不受容器宽度限制。fit-content
: 在最大宽度,最小宽度和容器宽度之间计算一个最佳值, 计算公式:fit-content = min(max-content, max(min-content, container-space))
。content
: 内容自身的尺寸,不考虑容器的限制。(在 flex 布局中,content
和max-content
效果一致,只不过max-content
是 CSS 通用的值,content
专属于 flex-basis)。
grow
定义 item 在主轴上的增长系数。当容器宽度大于 items 默认总宽度时,剩余的空间会按照 grow 的系数来分配。
具体实现
所有 item 的 grow = 0,都保持默认宽度,不分配剩余空间
至少有一个 item 的 grow > 0,用如下公式计算每个 item 如何分配剩余空间:
当前 item 的增长宽度
= (剩余宽度
/所有 items 的 grow 系数之和
) *当前 item 的 grow 系数
shrink
定义 item 在主轴上的缩减系数。当 items 默认总宽度大于容器宽度时,items 多余的空间会按照 shrink 的系数来分配如何缩减。
具体实现
所有 item 的 shrink = 0,都保持默认宽度,不缩减。
至少有一个 item 的 shrink > 0,用如下公式计算每个 item 如何缩减多余的空间:
当前 item 的缩减宽度
= (剩余宽度
/所有 items 的 shrink 系数之和
) *当前 item 的 shrink 系数
item 模型中未设置 flex
属性的考虑
CSS 中的 flex
属性没有作为 etml-flex-item
模型的字段,因为它是 basis, grow, shrink
这三个的复合属性,应该在此模型的上层定义。
align
表示单个 item 在交叉轴的对齐方式,其值会覆盖容器的 items-align
(表示容器中的所有 items 在交叉轴的对齐方式)。支持设置以下的值:
auto
(默认值): 继承容器的items-align
值。如果容器未设置items-align
,回退到stretch
。stretch
: 项目拉伸以填满交叉轴空间。normal
: 同 stretch。flex-start
: 项目对齐交叉轴起点。flex-end
: 项目对齐交叉轴终点。baseline
: 项目的第一行文本基线与容器中所有其他项目的基线对齐。若项目无文本,则以其底部边缘为基线。
Flex 容器模型
我们先来了解 Flex 布局用到的每个字段的对布局影响,然后考虑如何将这些字段的组合渲染为布局之后的字符串。
etml-flex.el
(defclass etml-flex (etml-block)
((display :initarg :display :initform 'flex :type symbol
:documentation "容器是块级元素还是内联元素。")
(direction :initarg :direction :initform 'row :type symbol
:documentation "主轴方向。")
(wrap :initarg :wrap :initform 'nowrap :type symbol
:documentation "换行方式。")
(content-justify
:initarg :content-justify :initform 'flex-start :type symbol
:documentation "主轴对齐方式。")
(content-align
:initarg :content-align :initform 'stretch :type symbol
:documentation "多个主轴在垂直方向的对齐方式。")
(row-gap :initarg :row-gap :initform 0
:type number :documentation "多行之间的 gap。")
(column-gap :initarg :column-gap :initform 0
:type number :documentation "多列之间的 gap。")
(items :initarg :items :type (vector etml-flex-item)
:documentation "子项目列表")
(items-align
:initarg :items-align :initform 'stretch :type symbol
:documentation "所有 items 在交叉轴的对齐方式。"))
"ETML flex layout model.")
display
表示容器表现为块级别元素还是内联元素。支持设置为下面两种值:
flex
:渲染后的容器是一个块级别元素inline-flex
:渲染后的容器是一个内联元素
具体实现
这个值决定了 flex 容器渲染成字符串后是否要在最后加一个换行符。
flex
:最终渲染成的字符串最后加一行像素空格行inline-flex
:直接返回最终渲染成的字符串
direction
表示主轴的方向,即容器内的项目的排列方向。支持设置为下面四种值:
row
(默认值):主轴为水平方向,起点在左端。row-reverse
:主轴为水平方向,起点在右端。column
:主轴为垂直方向,起点在上沿。column-reverse
:主轴为垂直方向,起点在下沿。
具体实现
这个值决定了在所有 items block 的拼接方式。
row
:使用 etml-block-concat 水平方向拼接所有 itemsrow-reverse
:items 倒序之后,再使用 etml-block-concat 拼接column
:使用 etml-block-stack 垂直方向拼接所有 itemscolumn-reverse
:items 倒序之后,再使用 etml-block-stack 拼接
wrap
表示如果一条轴线排不下,如何换行。支持设置为下面三种值:
nowrap
:不换行,所有 items 排列在一行wrap
:换行,换行到上一行wrap-reverse
:换行,换行到下一行
在实现上,需要综合考虑该值与后续要介绍的 items-flex
(拉伸因子,收缩因子和基础宽度) 来计算每个 item 的最终宽度。如果计算后的所有 items 最终总宽度小于等于容器宽度,wrap
值没有影响。
如果计算后的所有 items 最终总宽度大于容器宽度,wrap
值会有如下影响:
不同 wrap
值在溢出时如何处理
nowrap
:溢出容器宽度但仍不换行,需要根据继承自etml-block
的overflow
属性处理。wrap
和wrap-reverse
:溢出容器部分的 items 按照属性要求进行换行;换行之后,原本行的 items 需要按照items-flex
重新计算每个 item 的宽度(比如需要拉伸)。
容器模型未设置 flow
字段的考虑
flow
是 direction、wrap
的复合属性,应该由上层的模型定义。
content-justify
定义了 items 在主轴上的对齐方式。支持设置为如下的值:
提醒
下面提到的 container-width
指的是 flex 容器容纳内容的宽度,不包含 padding, border 和 margin 部分。items-width
指的是 flex 容器中一行的 items 的总宽度,每个 item 的宽度是包含内容、padding、border 和 margin 的总宽度。items-num
指的是一行 items 的数量。
flex-start
起点对齐
items 从行(列)首开始排列。第一个 item 的主轴起始位置紧贴行(列)首边缘,后续 item 与前一个 item 紧贴排列。具体实现
items 结尾用
(- container-width items-width)
像素空格填充。flex-end
终点对齐
items 向行(列)尾方向排列。最后一个 item 的主轴结尾位置距紧贴行(列)尾边缘,其他 item 与后一个 item 紧贴排列。具体实现
items 开头用
(- container-width items-width)
像素空格填充。center
居中对齐
items 沿行中轴线排列。所有 items 彼此紧贴且整体居中,行(列)首边缘与第一个项目之间、行(列)尾边缘与最后一个项目之间留有等距空间(若剩余空间为负值,则项目会向两个方向等距溢出)。具体实现
items 两侧用
(/ (- container-width items-width) 2)
像素空格填充。space-between
两端对齐
items 均匀分布在行内。若剩余空间为负或仅存在单个 item 时,该值效果等同于 flex-start。其他情况下,首项的主轴主轴起始位置紧贴行(列)首边缘,末项的主轴结尾位置紧贴行(列)尾边缘,其余相邻项目间距保持相等。具体实现
在 items 之间用
(/ (- container-width items-width) (1- items-num))
像素空格填充space-around
环绕对齐
items 均匀分布,并在两端保留 item 间距的一半尺寸空间。若剩余空间为负或仅存在单个项目时,该值效果等同于 center。其他情况下,相邻项目间距相等,首项与容器边缘/末项与容器边缘的间距为相邻项目间距的一半。具体实现
开头和结尾用
(/ (container-width - items-width) (* 2 items-num))
像素空格填充,items 之间用(/ (container-width - items-width) items-num)
像素空格填充。space-evenly
均匀对齐
flex 项都沿着主轴均匀分布在指定的对齐容器中。相邻 flex 项之间的间距,主轴起始位置到第一个 flex 项的间距,主轴结束位置到最后一个 flex 项的间距,都完全一样。具体实现
开头和结尾 以及 items 之间都用
(/ (container-width - items-width) (1+ items-num))
空格像素填充。
content-align
多根轴线在交叉方向的对齐方式。
- flex-start
- flex-end
- center
- space-between
- space-around
- stretch
row-gap
多行之间的 gap 距离。
column-gap
多列之间的 gap 距离
items
子项列表
items-align
控制 items 在交叉轴上的位置(交叉轴指与主轴垂直的那个轴)