docs: explain layout example constraints and protocol calls

This commit is contained in:
2026-11-12 19:39:17 +08:00
parent a0545e7e40
commit 07812bc47a
7 changed files with 71 additions and 0 deletions
+10
View File
@@ -3,45 +3,55 @@
// 内部布局基于容器自己的约束;Page 和相机只提供外部展示/投影上下文。
// 文字 boxWidth=fill 占父分配的可用宽度,marginBottom 在文字边框盒外参与排布。
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
// fill 是共享对象的布局策略,每次出现仍从各自父约束得到实际宽度;不把首次测量宽度保存回 shared。
shared.boxWidth = "fill"
shared.marginBottom = 8
// 左 Column 使用 fill、min/max 宽度限制和四边边框;非对称 paddingLeft 覆盖统一 padding。
left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索"))
left.boxWidth = "fill"
// 给父 Row 的分配加上下限/上限,min/max 是 border-box 约束,不是原生窗口宽度。
left.minWidth = 180
left.maxWidth = 360
// 四边分别设置厚度,测量/安排时边框与 padding 占内容外空间;颜色只影响绘制。
left.borderLeft = 2
left.borderTop = 2
left.borderRight = 2
left.borderBottom = 2
left.borderColor = "#CBD5E1"
left.paddingLeft = 24
// Column 的交叉轴为横轴,未自行覆盖对齐的孩子按 center 排;共享文字还有 fill 自身宽度策略。
left.crossAlign = "center"
// 右 Column 也引用同一 shared,但最大宽只有 240,因此对应 Mount 的换行高度可以不同。
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
right.boxWidth = "fill"
right.maxWidth = 240
// 这里 end 仅选择右容器内的交叉轴位置,不改变 shared 业务对象在左容器的对齐。
right.crossAlign = "end"
// Row 分配有限 760 主轴空间,扣 padding/gap 后在两侧 fill 子项之间分配并服从上限。
content = Row(24, 24, left, right)
content.boxWidth = 760
// Row 的交叉轴为纵轴,start 让左右两列按可用上侧对齐,列高仍来自各自文字换行测量。
content.crossAlign = "start"
// 角标是 Overlay 的另一子项,alignSelf 与 margin 控制它在容器交叉轴上的位置。
badge = TextBlock("Endorphin · layout")
badge.marginRight = 12
badge.marginBottom = 12
// 子项显式对齐覆盖父级默认交叉轴对齐,margin 保留在边框盒外,不作为文字内容绘制。
badge.alignSelf = "end"
// 固定 800x400 的 Overlay 不按线性顺序挤开兄弟,overflow=clip 同时约束绘制和命中。
surface = Overlay(0, 0, content, badge)
surface.boxWidth = 800
surface.boxHeight = 400
// Overlay 的主轴对齐参与各子项在同一区域的分配,不像 Column 那样把 content 和 badge 顺序堆叠。
surface.mainAlign = "end"
// overflow 策略由宿主投影成子裁剪,超出有限表面的文字/按钮不能仅凭原布局盒穿透命中。
surface.overflow = "clip"
// Page 只提供展示入口,根容器自身的盒模型规则不依赖页面导航方式。
page = Page()
page.title = "嵌套盒模型"
// 顶层脚本执行到这里请求展示完整对象组合;布局驱动随后按出现约束求解,再发布不可变绘制帧。
page.render(surface)
+10
View File
@@ -3,6 +3,7 @@
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
class Strip: Row {
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
// source_layout 在该 Strip 出现的测量阶段调用,返回 MeasureResult 仍会按父传入 Constraints 钳制。
function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(340, 60)
}
@@ -12,15 +13,18 @@ class Actions {
first: RectBlock
second: RectBlock
// 两项 basis=100,按1:3分增长;首项 max=105 触顶后,多余增长转给第二项。
// 增长按钮的绑定回调,先复位首项下界/上界,再改 basis 与权重,最终布局在本次输入后的刷新中求解。
function grow(self: Actions): Unit {
self.first.flexMin = 0
self.first.flexMax = 105
// basis 提供参与分配的起始主轴尺寸,与 RectBlock 构造时自然宽度不同。
self.first.flexBasis = 100
self.second.flexBasis = 100
self.first.flex = 1
self.second.flex = 3
}
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
// 收缩按钮回调复位增长上界并设置下界,两个 flex=0 不参与增长,但 flexShrink=1 仍允许按 basis 收缩。
function shrink(self: Actions): Unit {
self.first.flexMax = 300
self.first.flexMin = 150
@@ -28,22 +32,28 @@ class Actions {
self.second.flexBasis = 200
self.first.flex = 0
self.second.flex = 0
// 权重是 flexShrink×basis,本例 basis 同为 200,首项触底后剩余缺额继续转到第二项。
self.first.flexShrink = 1
self.second.flexShrink = 1
}
}
// 自然宽20/40与basis可不同;fixed不设置权重,保持原始60宽。
first = RectBlock(20, 20, 4, "#A4C8F0")
// 两个可调对象分别用不同颜色展示,当前自然宽并不决定设置 flexBasis 后的起始分配。
second = RectBlock(40, 20, 4, "#A6D8B2")
// 未改 flex/basis/shrink 的固定项不承担本例缺额,保留 60 宽参与总宽计算。
fixed = RectBlock(60, 20, 4, "#E8C990")
actions = Actions(first, second)
grow = Button("增长:上限后重新分配")
// 注册零参源方法,由点击派发执行;下面显式 actions.grow() 才是在初次 render 前立即初始化。
grow.onClick(actions.grow)
shrink = Button("收缩:最小尺寸后重新分配")
shrink.onClick(actions.shrink)
// 先初始化增长策略再挂载,首次发布即采用105/135/60,不需等待按钮点击。
actions.grow()
// 340 总宽减左右 padding 共 20、两个 gap 共 20 和固定项 60,留给两项的主轴总量是 240。
strip = Strip(10, 10, first, second, fixed)
// 输出提示中的 105/135 或 150/90 基于 Strip 获得足够父约束的尺寸;实际几何仍由宿主约束校验。
page = Page()
page.title = "基础尺寸与边界"
page.render(Column(20, 12, Row(0, 10, grow, shrink), strip, TextBlock("增长:105 / 135 / 60;收缩:150 / 90 / 60。")))
+10
View File
@@ -2,25 +2,35 @@
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
class Actions {
// Actions 只持有首排填充块引用,不持有共享段落或第二排的固定矩形,按钮修改的对象范围因此明确。
filler: RectBlock
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
// Button.onClick 接受绑定后的零参回调,事件系统负责调用;这里改权重,不直接给 Mount 写最终几何。
function balanced(self: Actions): Unit { self.filler.flex = 1 }
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
// 同一父 Row 内 paragraph=1、filler=3 按剩余空间分配,父容器与子上限仍可进一步限制实际宽度。
function narrow(self: Actions): Unit { self.filler.flex = 3 }
}
// 共享文字的 flex=1 是业务布局策略,具体几何与自然尺寸缓存仍属于各 Mount。
paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
// 原生 measure 取得段落自然/换行尺寸,原生 arrange 分配主轴空间,源布局驱动可为分配宽度再次测高。
paragraph.flex = 1
// 自然尺寸 20×20 不等于 flex 后最终宽度;flex=1 让这个出现参与首排剩余空间分配。
filler = RectBlock(20, 20)
filler.flex = 1
// 绑定下面两个按钮的方法时捕获同一 Actions 实例,后续点击仍更新已挂载的 filler。
actions = Actions(filler)
balanced = Button("等比例分配")
// 注册监听器而不立即执行 balanced,所以第一次布局沿用上面已经设好的初始权重。
balanced.onClick(actions.balanced)
narrow = Button("文字只占四分之一")
// 另一个按钮只选择同一实例的方法,切换策略不重新构造两排容器或共享文字。
narrow.onClick(actions.narrow)
// 首排两个可伸缩子项,次排只有文字 flex 与固定 300px 兄弟,因此首排按钮不会改次排宽度。
first = Row(10, 10, paragraph, filler)
// 第二排的段落仍是同一业务对象,兄弟是未设置 flex 的固定宽矩形,分配宽度属于第二个出现。
second = Row(10, 10, paragraph, RectBlock(300, 20))
// 同一个 Page 的根中同时包含两个段落 Mount,修改文本会影响两处,修改首排 filler 权重只改变首排约束。
page = Page()
page.title = "按权重分配与文字重排"
page.render(Column(10, 10, Row(0, 10, balanced, narrow), first, second))
+10
View File
@@ -4,6 +4,7 @@
class WidthModel {
width: Number
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
// 按钮 click 的 Event 回调,只写 WidthModel.width;后续是否重测由已记录的真实读取失效决定。
function expand(self: WidthModel, event: Event): Unit {
self.width = 260
}
@@ -12,21 +13,26 @@ class WidthModel {
class Tint {
color: String
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
// 另一按钮的 Event 回调只写 Tint.color,不改 dimensions,避免用无关模型写入混淆测量依赖。
function blue(self: Tint, event: Event): Unit {
self.color = "#526AC7"
}
}
// 原生矩形的尺寸/颜色策略分别由源协议覆盖,业务模型通过字段持有。
class CachedRect: RectBlock {
// CachedRect 引用两个不同策略对象;measure 读尺寸对象,paint 读颜色对象,各阶段依赖分开记录。
dimensions: WidthModel
tint: Tint
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
// 只读测量由 source_layout 调用,self 是共享矩形,mount/constraints 是当前出现输入,不写 geometry。
function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(self.dimensions.width, 60)
}
// 复用原生矩形命令,再改新命令的颜色;修改新结果被允许,但 paint 不能修改已有业务对象。
function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList {
// 源 paint 由 source_paint 调用,显式委托基类得到本轮新 DisplayList,不在这里发输入事件或改变 Page。
drawing = RectBlock.paint(self, mount, geometry)
// 修改的是新分配命令的颜色字段,再由宿主统一解码校验;self.tint 仍只读。
drawing.commands.at(0).color = self.tint.color
return drawing
}
@@ -34,6 +40,7 @@ class CachedRect: RectBlock {
// 宽度来自 ui.viewport 的当前逻辑页面区域,显示窗口变化也能成为测量依赖。
class ViewportBand: RectBlock {
// 返回可用视口宽度,最终仍服从父容器约束;外层 padding 会减少分配宽度。
// ui.viewport 取当前窗口逻辑页面范围,实际返回尺寸还会受父级更窄的 Constraints 约束。
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(ui.viewport().width, 24)
}
@@ -41,12 +48,15 @@ class ViewportBand: RectBlock {
// 创建相互独立的宽度/颜色模型并绑定两个按钮,用相同对象观察缓存与可见结果。
dimensions = WidthModel(200)
tint = Tint("#ED6A5A")
// 源字段 dimensions/tint 放在原生构造参数后,RectBlock 的默认尺寸不是这个覆盖 measure 的最终输出。
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
band = ViewportBand(80, 24, 0, "#237A45")
blue = Button("只改绘制颜色")
expand = Button("修改测量依赖")
// 保存两个监听连接,颜色按钮绑定 tint,宽度按钮绑定 dimensions,不把 UI 事件对象用作缓存 owner。
blueConnection = blue.on("click", tint.blue)
expandConnection = expand.on("click", dimensions.expand)
// 根组合同时展示 viewport 依赖和业务尺寸依赖;按钮回调完成后宿主统一刷新,不在点击方法里手工重排。
root = Column(24, 16, TextBlock("改变颜色保留测量缓存;改变宽度重新测量"), band, rect, blue, expand)
page = Page()
page.render(root)
+10
View File
@@ -4,26 +4,33 @@
class Policy {
width: Number
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
// narrow/wide 按钮事件回调更新同一 policy.width,只有持有该策略的 ArrangedColumn 读取它。
function narrow(self: Policy, event: Event): Unit { self.width = 280 }
// 选择 560 逻辑像素宽度,段落变矮;另一个容器宽度不跟着改变。
function wide(self: Policy, event: Event): Unit { self.width = 560 }
}
// 在原生 Column 排列基础上覆盖第一个稳定子槽的宽度。
class ArrangedColumn: Column {
// 布局共享策略模型,不保存段落的全局最终宽度;某个出现的约束/分配由 Plan 独立记录。
policy: Policy
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
// source_layout 的安排阶段调用覆盖方法,基类利用本轮孩子测量值生成包含全部稳定 slot 的临时结果。
result = Column.arrange(self, mount, geometry)
// 只改返回的第一子槽 Bounds.width,宿主会验证槽完整性并据显式分配反推下一轮精确测量约束。
result.children.at(0).width = self.policy.width
return result
}
}
// 通过源 measure 改整个容器的期望宽度,以对比 arrange 分配路径。
class MeasuredColumn: Column {
// MeasuredColumn 自有策略字段,不是共享 paragraph 的 boxWidth,也不复制段落内容。
width: Number
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
// 测量阶段先委托基类求自然尺寸,再修改临时 MeasureResult;已有业务对象仍受只读限制。
result = Column.measure(self, mount, constraints)
// 宿主读取这个新结果字段后还会按 Constraints 钳制;源返回 width 不等于绕过上层几何预算。
result.width = self.width
return result
}
@@ -32,13 +39,16 @@ class MeasuredColumn: Column {
policy = Policy(280)
paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。")
tail = TextBlock("这一行跟随上方段落。")
// 首个孩子是 paragraph,故 at(0) 修改的是它的分配;tail 的位置在重新测高与收敛后随段落高度更新。
arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
// 另一出现仍用同一 paragraph,但父测量策略为 360,与上面的 policy.width 独立。
measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360)
narrow = Button("arrange 为 280")
wide = Button("arrange 为 560")
// 保留事件订阅引用,两个按钮调用同一 Policy 的不同方法。
narrowConnection = narrow.on("click", policy.narrow)
wideConnection = wide.on("click", policy.wide)
// 这两个父容器都在同一文档根,布局需要整树稳定再一起发布,不能先显示新宽度再补旧高度。
page = Page()
page.title = "约束协商"
page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured))
+10
View File
@@ -2,9 +2,11 @@
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
// 保存宽度与左偏移;两字段只在事件回调中修改。
class Metrics {
// width 提供 measure 输出,left 提供 arrange 的首子槽 x;模型状态不是 Mount 的派生几何副本。
width: Number
left: Number
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
// button.on 的绑定事件回调写入两个字段,方法返回后的刷新把受影响布局合并到同一帧。
function expand(self: Metrics, event: Event): Unit {
self.width = 260
self.left = 68
@@ -15,7 +17,9 @@ class MeasuredRect: RectBlock {
// 首次测量前结构 Mount 链接已建立,因此可从 parent.block 读取当前出现位置的父策略。
// 此阶段还不能查询最终 bounds,返回值使用 MeasureResult 名义类型。
function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult {
// 从当前出现而非共享 rect 查父 Mount,同一 MeasuredRect 换父容器时可读取不同的 metrics。
parent = mount.parent()
// 父 Block 需要符合示例的 metrics 约定;MeasureResult 宽高随后仍由宿主按当前 Constraints 检查和钳制。
return MeasureResult(parent.block.metrics.width, 60)
}
}
@@ -24,16 +28,22 @@ class OffsetColumn: Column {
metrics: Metrics
// Arrangement 里的 x 是当前排列使用的坐标;读取 metrics.left 成为排列依赖。
function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement {
// 保留原生 Column 对全部孩子的槽分配,临时结果可修改,不在 arrange 中写 self.metrics。
positions = Column.arrange(self, mount, geometry)
// 这里直接覆盖排列结果坐标为 metrics.left,不是给 geometry.x 再加相对位移;其他槽保持原生结果。
positions.children.at(0).x = self.metrics.left
return positions
}
}
// 将原生参数与源字段传给具体子类,button 事件更新模型,Page.render 建立完整展示关系。
metrics = Metrics(200, 44)
// 原生矩形构造参数仍提供绘制载荷,实际测量被源覆盖为模型宽度和固定高 60。
rect = MeasuredRect(80, 20, 0, "#526AC7")
button = Button("更新布局")
// 首个孩子是 rect,metrics 是 Column 子类新增字段,不是额外的视觉子项。
root = OffsetColumn(24, 16, rect, TextBlock("尺寸来自源码 measure,位置来自源码 arrange"), button, metrics)
// 保存监听连接使按钮操作同一个模型;布局方法由宿主协议调用,不由事件回调直接手工调用。
connection = button.on("click", metrics.expand)
page = Page()
// 先建立出现树和临时 Mount 上下文,再运行 measure/arrange,源方法才能在求解阶段查看父关系。
page.render(root)
+11
View File
@@ -3,16 +3,19 @@
// 没有独立宽度副本;约束与换行结果属于出现位置,文本内容属于共享对象。
class Paragraph: TextBlock {
// 将当前 Mount 与 Constraints 交给原生文字测量,宽度变化触发重测高度。
// 宿主测量驱动调用源覆盖,源方法再显式调用原生基类,使用当前出现的可用宽度与字体系统计算高度。
function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult {
return TextBlock.measure(self, mount, constraints)
}
// 使用本次 geometry 画文字,和原生测量采用相同换行区域。
// 源绘制驱动在布局完成后传本轮 Bounds,原生文字命令携带对应换行边界,不重新猜另一套宽度。
function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList {
return TextBlock.paint(self, mount, geometry)
}
}
// 保存主窗口、共享文字和原全文,四个按钮分别改窗口尺寸或改文字内容。
class Actions {
// 只保存主窗口资源;narrow/wide 不改下面 other 的尺寸,两个窗口共享的是 paragraph 而非 viewport。
window: Window
paragraph: TextBlock
full: String
@@ -21,33 +24,41 @@ class Actions {
// 请求主窗口变宽,文字高度随之减小,不改另一窗口尺寸。
function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) }
// 用短文本替换同一对象内容,两个窗口的文字都重新测量。
// 短文本提示只是示例字符串;真实 paragraph 仍同时出现在两个窗口,修改内容不会把两处 Mount 合成一个。
function short(self: Actions, event: Event): Unit { self.paragraph.setText("短文本:同一个对象、同一个 Mount。") }
// 恢复保存的 full 字符串,不创建新的 Paragraph。
function restore(self: Actions, event: Event): Unit { self.paragraph.setText(self.full) }
}
full = "GUI 和代码操控同一套 Aster 内存对象。这个段落同时出现在两个窗口:对象内容相同,每个 Mount 根据自己的可用宽度换行。窗口变窄,段落高度增加,后面的对象向下移动;窗口变宽,段落高度减小,后面的对象向上移动。对象身份和已有 Mount 身份保持稳定。English words wrap at word boundaries; a_long_unbroken_identifier_also_wraps_when_it_cannot_fit_on_one_line. 中文、组合字符 é 与 emoji 👩‍💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
// 只创建一次共享业务对象,full 字符串另由 Actions 保存供 restore 恢复,不重新 new 一个段落实例。
paragraph = Paragraph(full)
// 后继文字没有硬编码 y,由 Column 安排读取前一个段落本轮实际高度来定位。
tail = TextBlock("这一行的位置由前面段落的实际高度决定。")
actions = Actions(ui.window(), paragraph, full)
narrow = Button("变窄")
wide = Button("变宽")
short = Button("短文本")
restore = Button("恢复段落")
// 四个连接各绑定同一 Actions 实例的不同事件方法;方法声明的 event 由点击派发提供。
narrowConnection = narrow.on("click", actions.narrow)
wideConnection = wide.on("click", actions.wide)
shortConnection = short.on("click", actions.short)
restoreConnection = restore.on("click", actions.restore)
// 主页面先展示四按钮、共享段落和 tail,页面与窗口身份和 paragraph 业务身份分别保存。
page = Page()
page.title = "约束下的文字换行"
page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail))
// 新窗口有独立可用宽度,但 second.render 仍用同一 paragraph,所以两个 Mount 可有不同高度。
// 创建同 VM 的另一个窗口资源,后面的 resize/title/move 是提交后由 GUI 执行的窗口命令。
other = Window()
other.resize(720, 500)
other.setTitle("同一个段落 · 另一组宽度")
other.moveTo(1700, 80)
// 第二页面使用不同的 Column 宽度上下文,再引用同一个 paragraph,产生独立换行测量和 Mount。
second = Page()
second.title = "共享 Paragraph"
second.render(Column(24, 16, paragraph))
// 将此页归到副窗口,不把主窗口 Page 或它的 Mount 直接复制过去。
second.open(other)
// 恢复主页面活动状态并设置原生窗口尺寸/标题/桌面位置,focus 命令在成功输入后交宿主执行。
page.activate()