@kurot/ui 2.1.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ # @kurot/ui 架构文档
2
+
3
+ > 当前版本:3.1.0,peerDependency `@kurot/core: ^2.1.0`。逐条变更记录见
4
+ > [CHANGELOG.md](../CHANGELOG.md)。
5
+ > 面向 AI 智能体的速查文档见 [ai-context.md](./ai-context.md)(目录地图、反
6
+ > 直觉行为清单、术语表、任务→文件速查表)。本文档面向人类读者,讲设计动机
7
+ > 与内部机制,两份文档不重复内容,互相引用。
8
+
9
+ ---
10
+
11
+ ## 一、概述
12
+
13
+ `@kurot/ui` 是从零重写的 EUI 兼容 UI 框架,建在 [`@kurot/core`](../../core/docs/architecture.md)
14
+ 之上。对 core 是 `peerDependency`,不打包进产物——依赖方项目需要显式安装
15
+ `@kurot/core`,这样多个使用 UI 库的包才能共享同一份 core 实例,避免出现
16
+ 两套独立的显示树/全局状态。
17
+
18
+ 整个包最核心的架构决策是**委托模式取代原型混入**:Egret EUI 用
19
+ `implementUIComponent()` 做 prototype 拷贝来模拟"一个类同时是 DisplayObject
20
+ 又是 UIComponent"的多继承效果,这种做法在 TypeScript 严格模式下没法表达
21
+ (拷贝出来的属性没有类型),也违反团队约定的"继承链不超过 2 层"规则。
22
+ `@kurot/ui` 换成了纯组合:`Group`/`Component` 都只 `extends Sprite`(继承链
23
+ 1 层),把全部布局状态和验证逻辑单独放进 `UIState` 类,`Group`/`Component`
24
+ 各持有一个 `UIState` 实例(`this.ui`),把 `IUIComponent` 接口的每个方法都
25
+ 写成一行委托。`UIState` 反过来只通过一个很窄的 `IUIOwner` 回调接口跟宿主
26
+ 通信,完全不知道宿主具体是 `Sprite` 还是别的什么——这个解耦是"皮肤系统"
27
+ 和"验证系统"能够分别独立演化、不互相牵连的前提。
28
+
29
+ ---
30
+
31
+ ## 二、验证循环(失效/校验机制)
32
+
33
+ ### 2.1 三阶段 + 三个独立队列
34
+
35
+ UI 组件的布局更新是延迟批处理的:属性修改不会立刻触发重排,而是把组件
36
+ "排队",等到 core ticker 下一次渲染前的 `callLater` 阶段统一处理,避免
37
+ 同一帧内多次修改属性导致重复计算,也保证未布局状态不会被渲染出来。
38
+
39
+ `Validator`(单例 `validator`)内部维护三个独立的 `DepthQueue`:
40
+ `_propsQueue`、`_sizeQueue`、`_displayQueue`。每个队列按 `$nestLevel`
41
+ (core 的 `DisplayObject` 字段,不是 UI 概念)分桶,`Map<number, DepthBin>`,
42
+ 每个 `DepthBin` 内部是数组 + `Set` 的组合——`Set` 提供 O(1) 的"是否已在
43
+ 队列里"去重检查,数组提供有序的弹出。
44
+
45
+ 三阶段按固定顺序执行,但**遍历方向不同**:
46
+
47
+ ```
48
+ validateProperties() 浅→深 commitProperties()
49
+ validateSize() 深→浅 measure()
50
+ validateDisplayList() 浅→深 updateDisplayList()
51
+ ```
52
+
53
+ 方向不是随意选的,反映的是数据依赖方向:
54
+
55
+ - **属性提交浅→深**:父组件的属性变化经常需要先"下推"给子组件(比如
56
+ `List` 先把 `data` 设到某个 renderer 上,这个 renderer 才能据此测量自己
57
+ 的文本尺寸)。父先处理,它在提交属性过程中新增/移除的子节点才能在同一轮
58
+ 里被正确地轮到。
59
+ - **测量深→浅**:容器的测量尺寸通常是子节点尺寸的函数(`VerticalLayout`
60
+ 求和、`BasicLayout` 求 union),必须先测完所有子节点才能正确测量父节点。
61
+ - **显示列表提交浅→深**:容器决定每个子节点该放在哪(调用
62
+ `child.setLayoutBoundsSize`/`setLayoutBoundsPosition`),子节点需要先拿到
63
+ 这个位置信息才能定稿自己的显示状态,所以还是父先处理。
64
+
65
+ `DepthQueue.shift()`(浅→深)用一个只会前进不会回退的 `_min` 游标;
66
+ `pop()`(深→浅)对称地用 `_max` 游标——这让连续多次 `shift()`/`pop()`
67
+ 调用摊销下来是 O(1),不需要每次都从头扫描所有深度桶。
68
+
69
+ ### 2.2 三阶段之间的"打断重来"问题
70
+
71
+ 三阶段分离本身有一个经典难题:`validateProperties()` 在处理对象 A 时,
72
+ 可能会调用某个已经跑过属性验证的对象 B 的 `invalidateSize()`,或者给某个
73
+ 兄弟节点新增属性失效。如果三个阶段各自傻乎乎地跑一遍就完事,这类"迟到"
74
+ 的失效请求会被漏掉,逻辑上它们其实属于*同一轮*校验,应该被处理。
75
+
76
+ `validateClient(target)` 是这个问题的正确解法,用于强制同步校验(比如把
77
+ 一个之前移除的组件重新加回舞台,或者显式调用 `component.validateNow()`):
78
+
79
+ 1. 把 `_targetLevel` 设成 `target.$nestLevel`(如果已经在另一个
80
+ `validateClient` 调用内部,则不覆盖——`_targetLevel === Infinity` 的
81
+ 检查保证了重入安全)。
82
+ 2. 跑一个 `while (!done)` 外层循环:
83
+ - 先浅→深清空 `_propsQueue` 里深度不超过 target 的部分,逐个跑
84
+ `validateProperties()`(跑之前检查 `obj.stage` 还在,跳过中途被移出
85
+ 舞台的对象)。
86
+ - 再深→浅清空 `_sizeQueue`。**关键点**:如果在测量过程中,`target` 子树
87
+ 内某个对象又触发了属性失效(`_clientPropsFlag` 被置位),就把一个
88
+ 元素塞回 `_propsQueue`,标记 `done = false` 并 `break`——强制外层循环
89
+ 从第一阶段重新开始。这就是"迟到的属性失效"能在显示列表阶段跑之前
90
+ 被重新处理的机制。
91
+ - 显示列表阶段同理,同时监视 `_clientPropsFlag` 和 `_clientSizeFlag`。
92
+ 3. `finally` 块无条件恢复 `_targetLevel` 并重新同步各阶段的标志位——这是
93
+ 为了保证哪怕某个 client 的 `validateProperties()` 抛出异常,`Validator`
94
+ 内部记账状态在下一次校验时仍然一致(`Validator.test.ts` 专门测试了
95
+ 这个异常路径)。
96
+ 4. `_clientPropsFlag`/`_clientSizeFlag` 只有在 `_targetLevel` 不是
97
+ `Infinity`(即当前正处在某次 `validateClient` 调用内)且失效对象的深度
98
+ 落在 target 子树内时才会被置位——普通的延迟调度路径完全不会触发这个
99
+ 检查,不产生额外开销。
100
+
101
+ 普通的延迟调度路径(`_schedule()` → `_flush()`)简单得多:没有打断重来的
102
+ 逻辑,也不追踪重入。三个阶段依次跑一遍,各自用普通的 `shift()`/`pop()`
103
+ 清空整棵树的队列(不像 `validateClient` 只处理某个子树)。跑完之后如果
104
+ 任何一个标志位仍是 true(说明校验过程中又产生了新的失效),就再排一次
105
+ core ticker 的下一次 `callLater`,不会在同一个调用栈里同步循环——这意味着普通路径
106
+ 下,一次级联的失效可能跨越好几帧才收敛,而 `validateClient` 保证一次调用
107
+ 内同步收敛。这是两条路径在"一致性 vs 延迟"上刻意做出的不同取舍。
108
+
109
+ ### 2.3 与渲染循环的顺序保证
110
+
111
+ `_schedule()` 通过 core 的 `ticker.callLater()` 安排 `_flush()`。Core 在
112
+ `Player.render()` 之前先执行 `flushCallLaters()`,因此动态挂载的组件会先完成
113
+ 属性提交、测量和布局,再产生首帧画面。这个顺序等价于 Egret Validator 通过
114
+ `Event.RENDER` 建立的渲染前校验契约,同时避免 UI 和渲染器各自维护独立 RAF
115
+ 所产生的先后竞争。
116
+
117
+ ---
118
+
119
+ ## 三、皮肤系统
120
+
121
+ ### 3.1 Skin 不是显示对象——这是相对 Egret EUI 最大的结构性差异
122
+
123
+ `Skin`(`components/Skin.ts:25`)是 `class Skin extends EventDispatcher<SkinEvents>`
124
+ ——一个普通数据持有者,不在显示树里出现。这跟 Egret EUI 里 Skin 本身就是
125
+ 一个真实的 `DisplayObjectContainer` 子类完全不同。`Skin` 唯一的职责是
126
+ 声明 `skinParts`(字符串数组)、`states`(State 列表)、`elementsContent`
127
+ (要挂载的显示对象数组),实际挂载动作由宿主 `Component` 的
128
+ `_setSkin()` 完成——`elementsContent` 里的每个显示对象被直接
129
+ `addChildAt` 到*宿主组件*上,不是挂到 `Skin` 对象上。
130
+
131
+ 这个设计选择的动机是让 `Skin` 保持纯粹的"声明式配置对象"角色,跟视觉
132
+ 渲染完全脱钩,`Component` 才是唯一的显示树节点。
133
+
134
+ ### 3.2 主题加载的完整链路
135
+
136
+ ```
137
+ Theme(url) 构造
138
+ → setTheme(this) 立即同步把自己注册为全局主题(还没加载完)
139
+ → _load(url) 用 adapter.getTheme(url, onSuccess, onError)
140
+ → _onConfigLoaded(raw) 解析 JSON,把 skins/styles 合并进内部表
141
+ → 若有 skinsJs 字段
142
+ → _loadSkinsModule() 动态 import 编译产物
143
+ → _onLoaded() _initialized = true,处理 _delayList,派发 Event.COMPLETE
144
+ → 失败路径 → _onLoadFailed() 同样 _initialized = true + 处理 _delayList,
145
+ 但派发 IOErrorEvent.IO_ERROR
146
+ ```
147
+
148
+ 一个容易踩的坑:`Theme.ts` 里实际生效的默认适配器,是文件底部一个内联的
149
+ `_defaultThemeAdapter` 对象(用 `XMLHttpRequest`),**不是**公开导出的
150
+ `DefaultThemeAdapter` 类(`core/DefaultThemeAdapter.ts`,用 `fetch()`)。
151
+ `_load()` 只在构造 `Theme` 时显式传入自定义 adapter 才会绕开内联的那个。
152
+ `DefaultThemeAdapter` 目前是导出了但没有任何内部代码路径会用到它,只有
153
+ 调用方自己 `new DefaultThemeAdapter()` 传进去才会被激活——这是一处可以
154
+ 清理的重复实现,两个 adapter 分别用 XHR 和 fetch,逻辑没有共享,将来
155
+ 修 bug 容易只改一边漏另一边。
156
+
157
+ ### 3.3 编译期产物如何在运行时被找到——globalThis 是事实上的皮肤注册表
158
+
159
+ `@kurot/cli` 编译 KUI XML Skin 时,为每个主题生成一个索引模块,做的事情等价于:
160
+
161
+ ```js
162
+ import { createButtonSkin as s0 } from './skin0.js';
163
+ globalThis['skins.ButtonSkin'] = s0;
164
+ ```
165
+
166
+ `Theme._loadSkinsModule()` 相对主题配置文件自身的 URL 解析出
167
+ `skinsJs` 路径,动态 `import()` 这个模块。因为 ESM 模块求值是自顶向下
168
+ 同步执行的,`import()` 的 Promise resolve 的那一刻,这个 bundle 里的每个
169
+ 皮肤 factory 已经把自己挂到 `globalThis` 上了。
170
+
171
+ 真正的读取发生在后面,`Component._parseSkinName()` 处理字符串形式的
172
+ `skinName` 时做 `(globalThis as Record<string, unknown>)[skinName]`。
173
+ 所以完整链路是:
174
+
175
+ ```
176
+ CLI 编译期 globalThis["skins.X"] = factory (import 副作用)
177
+ Theme 配置文件 hostComponentKey → "skins.X" (字符串映射)
178
+ Component 赋皮肤时 globalThis["skins.X"] (读取拿到 factory)
179
+ ```
180
+
181
+ 没有独立的注册表对象——`globalThis` 本身就是皮肤 factory 的注册表。这是
182
+ 刻意换来简单性的设计,代价是**皮肤类名是一个事实上的全局命名空间**:两个
183
+ 主题如果凑巧用了同名的皮肤类(比如都叫 `"skins.ButtonSkin"`),后加载的
184
+ 会直接覆盖前一个在 `globalThis` 上的槏位。
185
+
186
+ ### 3.4 组件创建早于主题加载完成——`_delayList` 机制
187
+
188
+ 游戏代码经常需要在网络请求(拉取主题配置)完成之前就先把 UI 树搭好。
189
+ `Component.createChildren()` 如果没有显式设置 `skinName`,会去问全局
190
+ `Theme` 要一个默认皮肤名:
191
+
192
+ - 如果主题还没初始化完(`!theme._initialized`),`Theme.getSkinName()`
193
+ 把这个组件塞进 `_delayList`(去重),当下返回空字符串——**组件先以
194
+ 完全没有皮肤的状态创建出来**。
195
+ - 主题加载完成(成功或失败都算)后,`_handleDelayList()` 遍历这个队列:
196
+ 对每个还没被用户手动设置过 `skinName` 的组件,重新解析出真正的皮肤名,
197
+ 调用 `component._applySkinName()` 补上皮肤,触发一次重新验证。
198
+
199
+ 净效果:早创建的组件会有一到几帧渲染成"裸组件",主题加载完(含 skinsJs
200
+ 模块的动态 import)之后被追加皮肤——这是让引擎能够异步解耦主题加载和
201
+ UI 树构建的机制,跟 Egret 的异步主题加载惯例是一致的。
202
+
203
+ ### 3.5 `_setSkin` 的拆装流程
204
+
205
+ `_parseSkinName()` 根据 `skinName` 的类型分派:函数 → 直接当 factory 调;
206
+ 字符串 → 先查 `globalThis` 再当 factory 调;对象 → 直接当 `Skin` 实例用。
207
+
208
+ `_invokeSkinFactory()` 用正则 `/^class\s/.test(Function.prototype.toString.call(fn))`
209
+ 区分真正的 ES class 构造器(用 `new` 调)和 KUI XML 编译产生的 factory 函数(用 `fn.call(this)` 调,让 factory 获得宿主组件上下文)。
210
+
211
+ `_setSkin(skin)` 做的事:
212
+
213
+ 1. **拆旧**:调用 `onSkinRemoved()`,此时旧的完整 `skinParts` 仍可读取
214
+ → 标记 Skin 未就绪并清空内部 part 映射
215
+ → `oldSkin.hostComponent = undefined` 退出旧状态
216
+ → `oldSkin.unwatchAll()` 清空皮肤激活期间注册的 `Watcher`
217
+ → 把 `elementsContent` 从宿主上摘掉。
218
+ 2. **装新**:遍历 `skin.skinParts`,通过 `skin.getPart(name)` 一次性构建
219
+ 新的内部 part 映射 → 按声明顺序把 `elementsContent` `addChildAt` 到宿主
220
+ → 设置 `skin.hostComponent = this` 初始化状态机 → 标记 Skin 就绪并调用
221
+ `onSkinReady()`。组件实例上不会动态生成同名属性。
222
+ 3. 收尾 `invalidateSize()` + `invalidateDisplayList()` + 在*组件*(不是
223
+ 皮肤)上派发 `Event.COMPLETE`。
224
+
225
+ ---
226
+
227
+ ## 四、视图状态(State/Override 系统)
228
+
229
+ ### 4.1 状态切换的实际算法——没有真正的"diff"
230
+
231
+ `IOverride` 只有两个方法:`apply(host, skin)`、`remove(host, skin)`。
232
+ `Skin._applyState(fromState, toState)` 的实现是:
233
+
234
+ ```
235
+ oldState = states.find(s => s.name === fromState)
236
+ 若存在:对 oldState.overrides 逐个调用 override.remove(host, this)
237
+
238
+ newState = states.find(s => s.name === toState)
239
+ 若存在:对 newState.overrides 逐个调用 override.apply(host, this)
240
+ ```
241
+
242
+ **这就是全部逻辑**——无条件撤销旧状态的每个 override,无条件应用新状态
243
+ 的每个 override,**没有对比新旧两个状态的 override 列表、找出实际变化
244
+ 的部分**。如果同一个 `IOverride` 实例(或者逻辑等价的两个实例)同时出现
245
+ 在新旧两个状态里,它会被撤销然后立刻重新应用——对 `SetProperty`/
246
+ `SetStateProperty` 而言这只是白做一次无意义的往返(先恢复旧值再设成
247
+ 同一个新值),但对 `AddItems` 而言会造成一次多余的
248
+ "从容器移除→重新添加",可能带来 z-order 重置(如果没有重新指定完全
249
+ 一致的 `position`)。这是一个真实的性能/正确性细节,跟熟悉 Flex 那种
250
+ "只应用差集"的智能 diff 的开发者的直觉不符。
251
+
252
+ ### 4.2 三种 override 的具体行为
253
+
254
+ - **`SetProperty`**:目标是某个皮肤部件(或皮肤对象本身,`target` 为空时)
255
+ ,`apply()` 时先缓存旧值到 `_oldValue`,再赋新值;`remove()` 恢复
256
+ `_oldValue`,但只在 `_applied` 为真时才恢复(防御性检查,避免没有
257
+ `apply()` 过就调 `remove()`)。
258
+ - **`SetStateProperty`**:同样的模式,但目标是**宿主组件本身**
259
+ (`host[name] = value`),不是皮肤部件——用于状态驱动的、影响组件自己
260
+ 公开 API 的变化(比如 `enabled`),不是内部视觉细节。
261
+ - **`AddItems`**:`apply()` 时把某个显示对象移到目标容器里(默认是宿主)
262
+ 的指定位置;`remove()` 时把它移出,但只在
263
+ `item.parent === dest` 时才动手——防御性检查,避免这个对象在中途被
264
+ 别的逻辑挪去了别处。
265
+
266
+ ### 4.3 `Skin` 与 `Group` 各自维护一套几乎相同的状态机——已知的重复
267
+
268
+ `Group` 没有附着的 `Skin`(容器本身不换肤),所以它自己复刻了一份跟
269
+ `Skin._applyState` 结构上完全相同的 apply/remove 逻辑(`Group.ts` 的
270
+ `_commitCurrentState()`),直接持有 `_states`/`_statesMap`/`_currentState`
271
+ 字段。调用 override 时传的是 `override.apply(this as unknown as Component,
272
+ this as unknown as Skin)`——因为 `IOverride.apply/remove(host, skin)`
273
+ 这个签名假设的是一对独立的 `Component` + `Skin`,但 `Group` 只有自己
274
+ 一个对象,只能同时假扮两个角色(这能工作是因为 `Group` 状态实际用到的
275
+ override 通常是 `SetStateProperty`,从不真正调用 `skin.getPart()`)。
276
+
277
+ 这是一处已知的架构重复——两套结构相同的状态机分别长在 `Skin` 和 `Group`
278
+ 上,如果抽出一个共享的 `applyStateTransition(states, from, to, host, skin)`
279
+ 辅助函数,能去掉大约 30 行重复代码和那处不安全的双重类型转换。目前
280
+ 未做这个抽取,属于已知但尚未处理的技术债,不是刻意的设计。
281
+
282
+ ---
283
+
284
+ ## 五、布局系统
285
+
286
+ ### 5.1 measure/updateDisplayList 契约
287
+
288
+ `LayoutBase`(抽象基类)声明两个抽象方法,每个具体布局都要实现:
289
+
290
+ - **`measure()`**——深→浅阶段调用。任务是根据每个子节点的
291
+ `getPreferredBounds()`,算出容器自己的期望尺寸,调用
292
+ `target.setMeasuredSize(w, h)` 恰好一次。
293
+ - **`updateDisplayList(width, height)`**——浅→深阶段调用,传入的是*实际*
294
+ 分配到的宽高(可能跟测量尺寸不同,如果父节点做了约束)。任务是给每个
295
+ 纳入布局的子节点调用 `setLayoutBoundsSize()` + `setLayoutBoundsPosition()`
296
+ ,最后调用一次 `target.setContentSize(w, h)` 报告实际占用尺寸(用于
297
+ 滚动/视口计算,跟测量尺寸是两个不同的概念)。
298
+
299
+ `ILayoutTarget` 是布局算法需要从容器那里拿到的最小接口
300
+ (`numChildren`/`getChildAt`/`scrollH`/`setMeasuredSize`/... )。`Group`
301
+ 和 `Component` 都实现了这个接口,所以同一批布局类(`BasicLayout`、
302
+ `VerticalLayout` 等)既能当 `Group.layout` 用,也能被 `Component` 内部
303
+ 用作默认布局(`Component` 的度量/显示列表更新其实是委托给一个共享的
304
+ `BasicLayout` 单例实例,通过临时设置 `.target` 复用)。
305
+
306
+ `includeInLayout` 过滤:每个布局遍历子节点时会跳过没有实现
307
+ `getPreferredBounds()`(鸭子类型检查)或者 `includeInLayout === false`
308
+ 的对象——这就是 EUI 风格的 `includeIn`/`excludeFrom` 状态覆盖能"从布局里
309
+ 隐藏一个元素但不把它移出显示树"的底层机制。
310
+
311
+ ### 5.2 百分比尺寸
312
+
313
+ `BasicLayout`(约束布局)解析 `left`/`right`/`top`/`bottom`/
314
+ `horizontalCenter`/`verticalCenter` 加 `percentWidth`/`percentHeight`。
315
+ 只有当对应方向的两个位置约束**没有同时设置**(`left` + `right` 同时给出
316
+ 意味着宽度已经被算死了,`unscaledWidth - right - left`,这时优先于
317
+ `percentWidth`)百分比才会生效。字符串形式的约束值(KUI XML 中写
318
+ `left="10%"`)由 `fmt()` 辅助函数统一解析:以 `%` 结尾的当百分比处理,
319
+ 否则强转数字——没有单独的"百分比 vs 像素"类型标签。
320
+
321
+ `LinearLayoutBase.flexChildrenProportionally()`(`VerticalLayout`/
322
+ `HorizontalLayout` 共用)实现的是带 min/max 夹紧的迭代式百分比分配:用
323
+ `do...while(!done)` 循环反复找出"按比例分配会超出自己 min/max 限制"的
324
+ 子节点,把它夹到边界值,从剩余待分配池里移除(原地分区交换,不做数组
325
+ 拼接),把它的 `percent` 从总百分比里减掉,再对剩下的子节点重新分配——
326
+ 这不是一次除法就能算完的,因为一个子节点被夹住之后,其他子节点的"有效
327
+ 百分比份额"要跟着变。这是 Flex 盒模型百分比分配算法的移植。
328
+
329
+ ### 5.3 间距与内边距
330
+
331
+ `LinearLayoutBase` 持有 `_gap`/`_paddingLeft/Right/Top/Bottom`(setter
332
+ 统一调用 `_invalidateTargetLayout()`)。`VerticalLayout`/`HorizontalLayout`
333
+ 在分配空间之前先减掉 `(numElements - 1) * gap`,第二遍定位时每两个元素
334
+ 之间加上 `gap`。`TileLayout` 单独维护 `_horizontalGap`/`_verticalGap`
335
+ 和自己的 padding 字段——它直接 `extends LayoutBase`,不经过
336
+ `LinearLayoutBase`,所以 padding 的 getter/setter 是重复实现的一份,不是
337
+ 共享的。
338
+
339
+ ### 5.4 虚拟布局
340
+
341
+ 虚拟布局(`layout.useVirtualLayout = true`,默认关闭,这是相对 Egret
342
+ EUI 的一处差异)存在的意义是让 `List`/`DataGroup` 面对成千上万条数据时
343
+ 只实例化当前可见范围内的 renderer:
344
+
345
+ - **`elementSizeTable: number[]`**(`LinearLayoutBase` 的受保护字段)
346
+ 按数据下标(不是显示子节点下标,因为屏幕外的元素根本没被实例化)缓存
347
+ 每个元素沿布局轴的最近一次已知尺寸。`NaN` 表示"还不知道,用
348
+ `typicalWidth`/`typicalHeight` 估算"(默认 71×22)。
349
+ - **`elementAdded(index)`/`elementRemoved(index)`** 让尺寸表跟随
350
+ `ICollection` 的增删同步,不需要整体重新测量。
351
+ - **`getStartPosition(index)`**:累加 `elementSizeTable` 得到某下标的
352
+ 起始偏移;配合 `findIndexAt`(递归二分查找)可以反过来从滚动位置推算出
353
+ 当前可见的下标范围。
354
+ - `updateDisplayListVirtual()` 只遍历 `startIndex..endIndex`,先调用
355
+ `target.setVirtualElementIndicesInView(startIndex, endIndex)`——这是
356
+ `DataGroup` 用来回收/实例化对应下标范围的 renderer 的钩子。
357
+ - `measureVirtual()` 靠 `getElementTotalSize()`(缓存尺寸求和,未知条目
358
+ 用估算尺寸补)加上一次只针对当前可见范围的修正测量,来估算总可滚动
359
+ 尺寸——不需要为了拿到一个近似总长度就把所有屏幕外条目都实例化一遍。
360
+ - `TileLayout` 的虚拟模式不用 `elementSizeTable`,而是直接用
361
+ `scrollH`/`scrollV` 除以固定的 `列宽+间距`/`行高+间距` 算出可见下标
362
+ 范围(`_getIndexInView()`)——因为瓦片布局的格子本身就是等宽等高的,
363
+ 比线性布局的可变尺寸虚拟化简单得多。
364
+
365
+ **给未来扩展布局系统的人的提示**:`LayoutBase.getElementIndicesInView()`
366
+ 这个公开方法默认返回空数组,而 `VerticalLayout`/`HorizontalLayout`/
367
+ `TileLayout` 各自实现的其实是名字不同的**受保护**方法
368
+ (`getIndexInView()`/`_getIndexInView()`),**没有一个真正重写了基类的
369
+ 这个公开方法**。也就是说 `LayoutBase.getElementIndicesInView()` 目前是一处
370
+ 没有被实际接上任何逻辑的、看起来像扩展点但并不是的公开 API——新写一个
371
+ 布局类不要假设覆写它就能拿到虚拟可见范围。
372
+
373
+ ---
374
+
375
+ ## 六、组件类层级(完整组件目录)
376
+
377
+ ### 继承 `Component`(可换肤,单一视觉宿主,`skin`/`currentState` 委托给 `Skin`)
378
+
379
+ | 类 | 一句话说明 |
380
+ | ------------------------------------- | ------------------------------------------------------------------------------------------ |
381
+ | `Component` | 所有可换肤组件的抽象基类——皮肤生命周期、视图状态委托、约束布局属性、校验委托给 `UIState`。 |
382
+ | `Rect` | 矩形图形基元,带填充/描边。 |
383
+ | `Image` | 展示通过 `IAssetAdapter` 解析出的位图数据。 |
384
+ | `Label` | 文本展示,包装一个 `TextField`。 |
385
+ | `EditableText`(继承 `Label`) | 支持占位提示的可编辑文本框。 |
386
+ | `Button` | 带 up/down/disabled 状态的可点击按钮;`toggle` 标志让子类实现自动切换 `selected`。 |
387
+ | `ToggleButton`(继承 `Button`) | 默认 `toggle = true`,每次点击自动翻转选中状态。 |
388
+ | `CheckBox`(继承 `ToggleButton`) | 功能与 `ToggleButton` 相同,视觉区别完全由皮肤决定。 |
389
+ | `RadioButton`(继承 `ToggleButton`) | 通过独立的 `RadioButtonGroup` 实现互斥选择。 |
390
+ | `ToggleSwitch`(继承 `ToggleButton`) | 皮肤变体,渲染成滑动开关样式。 |
391
+ | `Range` | 数值限制在 `[minimum, maximum]`,可选 `snapInterval`,是滑块的基类。 |
392
+ | `SliderBase`(继承 `Range`) | 拖动 thumb/track 皮肤部件来改变数值的抽象滑块行为。 |
393
+ | `HSlider`(继承 `SliderBase`) | 水平方向,从左到右。 |
394
+ | `VSlider`(继承 `SliderBase`) | 垂直方向,从下到上。 |
395
+ | `ScrollBarBase` | 滚动条基类——拖动 thumb 跟视口滚动位置绑定。 |
396
+ | `HScrollBar`(继承 `ScrollBarBase`) | 水平滚动(`scrollH`)。 |
397
+ | `VScrollBar`(继承 `ScrollBarBase`) | 垂直滚动(`scrollV`)。 |
398
+ | `ProgressBar` | 通过裁剪的 thumb(`scrollRect`)展示任务进度,支持标签格式化。 |
399
+ | `TextInput` | 文本输入框,支持占位提示和密码遮罩。 |
400
+ | `Panel` | 带可选标题栏/关闭按钮/拖拽区域的可换肤容器。 |
401
+ | `Scroller` | 包装一个 `IViewport`(通常是 `Group`),提供触摸滚动和滚动条管理。 |
402
+ | `ComboBox` | 下拉选择组件——触发按钮 + 悬浮下拉列表。 |
403
+ | `ItemRenderer` | `DataGroup`/`List`/`TabBar` 使用的数据驱动列表项渲染器基类。 |
404
+
405
+ ### 继承 `Group`(纯容器,不换肤,自带一套轻量状态机,持有可插拔的 `LayoutBase`)
406
+
407
+ | 类 | 一句话说明 |
408
+ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
409
+ | `Group` | 容器基类:参与校验循环,把子节点定位委托给 `LayoutBase`,自带一套状态机(跟 `Skin` 的状态机结构重复,见 4.3 节)。 |
410
+ | `DataGroup`(继承 `Group`) | 把 `ICollection` 数据源转换成 item renderer 实例,维护一个可复用的 renderer 空闲池支持虚拟布局回收。 |
411
+ | `ListBase`(继承 `DataGroup`) | 增加 `selectedIndex`/`selectedItem` 选中状态和 `requireSelection` 语义。 |
412
+ | `List`(继承 `ListBase`) | 增加点击 renderer 选中的触摸交互。 |
413
+ | `TabBar`(继承 `ListBase`) | 一排可选中标签,默认 `requireSelection = true`。 |
414
+ | `ViewStack` | 堆叠子节点的导航容器,任意时刻只显示一个;实现 `ICollection`。 |
415
+ | `UILayer` | 顶层容器,监听 `RESIZE` 自动跟随舞台尺寸。 |
416
+
417
+ ### 不属于 `Component`/`Group` 层级的支持类
418
+
419
+ | 类 | 一句话说明 |
420
+ | ------------------ | ---------------------------------------------------------------------------------------------------------------- |
421
+ | `Skin` | 数据持有者(继承 `EventDispatcher`)——声明 `skinParts`/`states`/`elementsContent`,自己不在显示树里(见第三节)。 |
422
+ | `RadioButtonGroup` | 继承 `EventDispatcher`——追踪同一 `groupName` 下多个 `RadioButton` 的互斥关系,派发 `PropertyEvent`。 |
423
+ | `TouchScroll` | 触摸滚动的惯性物理模拟,被 `Scroller` 使用。 |
424
+ | `Animation` | 简单数值缓动工具,被 `TouchScroll` 使用,靠 core 的 `ticker` 驱动。 |
425
+
426
+ ---
427
+
428
+ ## 七、与 Egret EUI 的对应关系
429
+
430
+ | Egret EUI 概念 | Kurot UI 对应 | 说明 |
431
+ | -------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
432
+ | `eui.Component` | `Component` | 角色相同,Kurot 用真正的类继承(`extends Sprite`)取代 Egret 的命名空间混入风格。 |
433
+ | `eui.Skin` | `Skin` | Egret 的 Skin 通常是 EXML 生成的真实 `DisplayObjectContainer` 子类;Kurot 的 `Skin` 明确**不是**显示对象——这是相对 Egret 最大的结构性偏离(见第三节)。 |
434
+ | `eui.UIComponent` 的三阶段校验 | `UIState` + `Validator` | 同样按深度执行三阶段批处理(提交属性 → 测量 → 提交显示列表)。Egret 通过 `Event.RENDER` 保证绘制前校验,Kurot 通过 core ticker 的 `callLater` 队列实现同一时序契约。 |
435
+ | `eui.IThemeAdapter` / 框架内置的主题单例 | `IThemeAdapter` + `Theme`/`getTheme()`/`setTheme()` | Egret 的主题加载策略基本是框架内置的;Kurot 把网络请求策略做成了可通过构造函数注入的接口(见下方"全新设计")。 |
436
+ | 编译期 EXML 皮肤类的全局命名空间注册 | `globalThis["skins.X"] = factory` 自注册 ESM 导入 | 概念上是同一种"全局皮肤注册表"模式,但 Kurot 的编译产物生成的是 **factory 函数**而不是类,专门为了支持用 `.call(this)` 调用来绑定 `this` 上下文——这是类继承重写带来的新问题(原本 Egret 的命名空间混入模式不存在这个绑定问题),Kurot 特有的解法,在 Egret 里没有直接对应物。 |
437
+ | `State`/`SetProperty`/`SetStateProperty`/`AddItems` 覆盖机制 | 同名类,同一个 `IOverride` 接口 | 对 Egret/Flex 视图状态覆盖模式的相当直接的移植。 |
438
+ | `BasicLayout`/`VerticalLayout`/`HorizontalLayout`/`TileLayout` | 同名类,算法结构基本一致(百分比分配、间距/内边距、`elementSizeTable` 虚拟布局) | 相当接近的算法移植,包括 `flexChildrenProportionally` 的迭代夹紧法。 |
439
+ | 命名空间混入/原型拷贝的继承模型 | 标准 TypeScript 类继承 + 显式委托(`UIState`、`ILayoutTarget`) | 这是整个包最大的架构决策,不是某个具体功能点的差异——第一节已经详述。 |
440
+
441
+ **Egret EUI 里没有直接对应物、属于全新设计的部分:**
442
+
443
+ - `IThemeAdapter` 作为可在 `Theme` 构造函数注入的接口(Egret 把网络加载
444
+ 策略焊死在框架里)。
445
+ - 委托模式本身:`IUIOwner`/`UIState` 作为一个可以独立于任何具体
446
+ DisplayObject 子类存在的状态机对象,被 `Group`/`Component` 各自持有一份
447
+ (`this.ui`),而不是把校验状态直接塞进一个公共基类——这正是
448
+ `Group`/`Component` 能在只有 1 层继承的情况下都实现 `ILayoutTarget`
449
+ 的原因。
450
+ - `Skin` 作为非视觉数据持有者的设计(前面已详述)——这是相对 Egret
451
+ "皮肤即容器"模型的刻意偏离。
452
+
453
+ ---
454
+
455
+ ## 八、测试覆盖
456
+
457
+ `test/` 下 26 个文件,共 223 个测试用例。按覆盖领域分组:
458
+
459
+ | 领域 | 覆盖的文件 |
460
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
461
+ | 校验/主题基础设施 | `sanity`、`Theme`、`Validator` |
462
+ | 皮肤与状态 | `Skin`、`SkinAlignment`(针对真实 CLI 模板皮肤的集成测试)、`AddItems` |
463
+ | 数据绑定 | `Binding`、`Watcher` |
464
+ | 集合与数据驱动组件 | `ArrayCollection`、`DataGroup`、`ListBase`、`ItemRenderer`、`ComboBox`、`TabBar`、`ViewStack` |
465
+ | 具体组件 | `RadioButton`、`Range`、`Slider`、`ProgressBar`、`Scroller`、`Label`、`TextInput` |
466
+ | 跨组件行为 | `Enabled`(enabled/touchEnabled/touchChildren 的 egret 对齐回归测试)、`EventMapOverride`、`GestureLifecycle`、`TransformLayout` |
467
+
468
+ **已知覆盖缺口**:没有独立的 `Group.test.ts`(`Group` 的状态机只是通过
469
+ `DataGroup`/`ListBase`/`ViewStack` 的测试间接被覆盖);没有针对布局算法
470
+ 本身的独立测试文件(没有 `VerticalLayout.test.ts`/`TileLayout.test.ts`),
471
+ 布局正确性目前只通过 `TransformLayout.test.ts`、`Scroller.test.ts`、
472
+ `SkinAlignment.test.ts` 这类组件级测试间接验证。如果要专门补测试,这两块
473
+ 是优先级最高的空白。
474
+
475
+ `examples/benchmark/` 另外提供真实 Chromium 中的 UI 性能验证,覆盖 400
476
+ 节点静态界面、240 节点 transform/alpha 动画和 10,000 条数据的虚拟列表。
477
+ 它分别记录 frame/render time、draw calls、校验阶段调用和 ItemRenderer
478
+ 创建/复用;该 benchmark 是自身回归基线,不承担跨 UI 框架排名。
@@ -0,0 +1,94 @@
1
+ # Label text layout and fitting
2
+
3
+ This contract applies to UI 3.1.0 with Core >= 2.1.0 within Core 2.x. The public
4
+ `TextFitMode` type is `none | shrink`. Native UI depends only on Core. KUI
5
+ authoring uses ui-document 0.9.x and matching CLI/ui-runtime releases; older
6
+ consumers must update their dependency ranges before using the new fields.
7
+
8
+ ## Lines and bounds
9
+
10
+ - `Label.multiline` defaults to `true`. Text wraps at a finite available width;
11
+ hard separators also create lines. Automatic height measures all those lines.
12
+ - `multiline = false` displays only the first hard-separated line, without
13
+ automatic wrapping. Overflow is clipped by the available bounds. Original
14
+ `text` is retained, including any hidden later lines.
15
+ - `wordWrap = true` chooses Unicode line-break opportunities, with the Core
16
+ dictionary and overflow tailoring. `false` chooses character boundaries.
17
+ This property has no effect while `multiline` is false.
18
+ - A fixed height limits the visible area. It does not change the line count or
19
+ switch a multiline label into single-line mode.
20
+ - `EditableText` defaults to single-line input. Core `TextField` defaults to
21
+ multiline in dynamic mode and single-line in input mode until `multiline` is
22
+ explicitly assigned. An assignment persists across later type changes.
23
+ - CRLF, CR, LF, VT, FF, NEL, line separator and paragraph separator are hard
24
+ separators. In single-line mode, only the content before the first is drawn.
25
+ A literal backslash followed by `n` remains literal text in KUI strings.
26
+
27
+ Explicit `multiline="false"` in older skins now means a real single line.
28
+ Remove it or set it to `true` for text intended to wrap. No files are migrated
29
+ automatically; omitted Label flags continue to allow wrapping.
30
+
31
+ ## Shrink to fit
32
+
33
+ ```xml
34
+ <Label id="labelDisplay" text="KZT 10 000,00" width="140" size="24"
35
+ multiline="false" textFit="shrink" minFontSize="16" />
36
+ ```
37
+
38
+ `textFit` accepts `none` (default) and `shrink`. Shrinking applies to ordinary
39
+ single-line Labels. Multiline Labels retain their authored size; EditableText
40
+ rejects `shrink`, including through the shared authoring schema. Put the policy
41
+ on a Button's `labelDisplay` skin part rather than on the Button itself.
42
+
43
+ - `size` is always the authored/base size. `renderedSize` is the derived drawing
44
+ size after layout validation. State overrides capture and restore `size`.
45
+ - Fit starts from that base size on every metric change. Shorter text or a
46
+ larger region restores the base size; text never grows beyond it.
47
+ - `minFontSize` defaults to 12 logical pixels, must be finite and at least 1,
48
+ and acts as a lower bound. A minimum above `size` never enlarges the text.
49
+ - Measurement uses the displayed text, font family, bold and italic settings,
50
+ and reserves twice the outline thickness on each constrained axis. The
51
+ ordinary Core clipping and outline rendering rules remain in effect.
52
+ - Available bounds come from fixed dimensions, parent layout constraints or
53
+ explicit maximum dimensions. Icon spacing belongs to the skin's Label
54
+ region. Automatic measured dimensions are not fed back as fit constraints.
55
+ - A finite zero-sized region is constrained. An automatic axis is unconstrained.
56
+ - Search performs at most 16 metric comparisons after the endpoint checks.
57
+ Intermediate results are rounded down to tenths of a logical pixel while
58
+ retaining the minimum. Base sizes and minimums may be fractional.
59
+ - If text still cannot fit at the minimum, `textFitOverflow` is true after
60
+ validation and ordinary clipping remains. The project can shorten its
61
+ wording, change formatting or enlarge the region. The engine never changes
62
+ amounts, translations, currency symbols or the authored text.
63
+ - The fit result is cached for unchanged metrics and bounds. Recalculation is
64
+ part of UI validation rather than a per-frame or project-wide scan.
65
+
66
+ These two read-only results are runtime observations, not authored KUI fields.
67
+ CLI emits the policy and base size; ui-runtime uses the same native Label. Neither
68
+ writes the derived size into XML, styles, semantic documents or history.
69
+
70
+ Load fonts before constructing or measuring text where possible. If a font
71
+ becomes ready later, call `label.invalidateSize()` before the next validation;
72
+ this clears both fitting and Core line metrics even when the family name stays
73
+ the same. Direct Core users call `textField.invalidateTextMetrics()`.
74
+
75
+ ## Verification
76
+
77
+ Core tests cover dynamic/input defaults, explicit mode changes, every hard
78
+ separator, width wrapping and fixed-height clipping. UI tests cover multilingual
79
+ and currency strings, minimum overflow, outlines, font readiness, state size
80
+ restoration, password display, automatic dimensions and parent resize stability.
81
+ Native Button tests cover the label's actual available region, changing content,
82
+ disabled-state restoration and replacing the active skin without updating the
83
+ detached label.
84
+ Shared-schema, CLI and ui-runtime tests cover XML round trips, invalid policies,
85
+ read-only boundaries and materialized preview without authored-document changes.
86
+ Tests use deterministic Canvas measurement doubles; actual platform fonts can
87
+ produce different fitted sizes.
88
+
89
+ Browser verification also covers WebGL and Canvas with Chakra Petch regular and
90
+ bold fonts from the KUI example, plus platform fallback for Chinese. It checks
91
+ single-line fitting, Unicode wrapping without leading separator spaces, outline
92
+ allowance, minimum overflow, native Button state restoration and invalidation
93
+ after a font finishes loading. Fitted sizes are platform-dependent; these checks
94
+ assert the layout contract rather than a universal numeric result.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurot/ui",
3
- "version": "2.1.4",
3
+ "version": "3.1.0",
4
4
  "description": "EUI-compatible UI component framework for the Kurot game engine — skin-based components, layouts, data binding, view states",
5
5
  "author": "Kelvin <kyia.x52@gmail.com>",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "files": [
18
18
  "dist/**/*",
19
+ "docs/**/*",
19
20
  "CHANGELOG.md",
20
21
  "LICENSE"
21
22
  ],
@@ -32,10 +33,10 @@
32
33
  "prepublishOnly": "npm run clean && npm run build"
33
34
  },
34
35
  "peerDependencies": {
35
- "@kurot/core": "^1.0.12"
36
+ "@kurot/core": "^2.1.0"
36
37
  },
37
38
  "devDependencies": {
38
- "@kurot/core": "^1.0.12",
39
+ "@kurot/core": "^2.1.0",
39
40
  "@playwright/test": "1.62.1",
40
41
  "@types/node": "^22.0.0",
41
42
  "happy-dom": "^20.8.9",