@kurot/ui 3.1.0 → 3.2.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.
@@ -1,478 +1,467 @@
1
- # @kurot/ui 架构文档
1
+ # @kurot/ui architecture
2
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
- > 与内部机制,两份文档不重复内容,互相引用。
3
+ > Package version: 3.2.0, with peerDependency `@kurot/core: ^2.2.0`.
4
+ > This release is prepared against published Core 2.2.0; UI publication is not yet confirmed.
5
+ > See [CHANGELOG.md](../CHANGELOG.md) for release-by-release changes.
6
+ > The [AI context map](./ai-context.md) provides directory, behavior, terminology
7
+ > and task-to-file references. This document explains design decisions and internal
8
+ > mechanisms for human readers; the two documents complement each other.
9
+ > Version 3.2.0 exports BitmapLabel for native code and programmatic skins.
10
+ > See [bitmap-label.md](./bitmap-label.md) for its resource and ownership contract.
8
11
 
9
12
  ---
10
13
 
11
- ## 一、概述
14
+ ## 1. Overview
12
15
 
13
- `@kurot/ui` 是从零重写的 EUI 兼容 UI 框架,建在 [`@kurot/core`](../../core/docs/architecture.md)
14
- 之上。对 core 是 `peerDependency`,不打包进产物——依赖方项目需要显式安装
15
- `@kurot/core`,这样多个使用 UI 库的包才能共享同一份 core 实例,避免出现
16
- 两套独立的显示树/全局状态。
16
+ `@kurot/ui` is an EUI-compatible UI framework rewritten on top of
17
+ [`@kurot/core`](../../core/docs/architecture.md). Core is a `peerDependency`,
18
+ excluded from the output bundle. Consumers explicitly install it so packages
19
+ using UI share one Core instance, display tree infrastructure and global state.
17
20
 
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
- 和"验证系统"能够分别独立演化、不互相牵连的前提。
21
+ The central architectural choice is delegation in place of prototype mixins.
22
+ Egret EUI uses `implementUIComponent()` to copy prototype members and combine
23
+ DisplayObject and UIComponent behavior. Such copied members are difficult to
24
+ represent in strict TypeScript. Kurot uses composition instead: Group and
25
+ Component both directly extend Sprite and hold a UIState instance as `this.ui`.
26
+ Their IUIComponent methods delegate layout state and validation to UIState.
27
+ UIState communicates with its host through the narrow IUIOwner callback interface,
28
+ without depending on the host's concrete DisplayObject subclass. This separation
29
+ allows skin and validation systems to evolve independently and follows the
30
+ repository preference for composition over deeper application inheritance.
28
31
 
29
32
  ---
30
33
 
31
- ## 二、验证循环(失效/校验机制)
34
+ ## 2. Validation cycle
32
35
 
33
- ### 2.1 三阶段 + 三个独立队列
36
+ ### 2.1 Three phases and three queues
34
37
 
35
- UI 组件的布局更新是延迟批处理的:属性修改不会立刻触发重排,而是把组件
36
- "排队",等到 core ticker 下一次渲染前的 `callLater` 阶段统一处理,避免
37
- 同一帧内多次修改属性导致重复计算,也保证未布局状态不会被渲染出来。
38
+ Layout updates are deferred and batched. Property changes enqueue components
39
+ for Core's render-preparation `callLater` queue instead of immediately repeating
40
+ layout work for each change within a frame.
38
41
 
39
- `Validator`(单例 `validator`)内部维护三个独立的 `DepthQueue`:
40
- `_propsQueue`、`_sizeQueue`、`_displayQueue`。每个队列按 `$nestLevel`
41
- (core 的 `DisplayObject` 字段,不是 UI 概念)分桶,`Map<number, DepthBin>`,
42
- 每个 `DepthBin` 内部是数组 + `Set` 的组合——`Set` 提供 O(1) 的"是否已在
43
- 队列里"去重检查,数组提供有序的弹出。
42
+ The Validator singleton (`validator`) maintains three DepthQueues:
43
+ `_propsQueue`, `_sizeQueue` and `_displayQueue`. Each groups clients by
44
+ `$nestLevel`, a Core DisplayObject field, using `Map<number, DepthBin>`.
45
+ Each DepthBin combines an array for ordered removal with a Set for constant-time
46
+ duplicate checks.
44
47
 
45
- 三阶段按固定顺序执行,但**遍历方向不同**:
48
+ The phases execute in a fixed order with different traversal directions:
46
49
 
47
- ```
48
- validateProperties() 浅→深 commitProperties()
49
- validateSize() 深→浅 measure()
50
- validateDisplayList() 浅→深 updateDisplayList()
50
+ ```text
51
+ validateProperties() shallow → deep commitProperties()
52
+ validateSize() deep → shallow measure()
53
+ validateDisplayList() shallow → deep updateDisplayList()
51
54
  ```
52
55
 
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
- 所产生的先后竞争。
56
+ These directions follow the data dependencies:
57
+
58
+ - Properties commit from parent to child. For example, List assigns data to a
59
+ renderer before the renderer measures its text. Children added or removed
60
+ during a parent commit can participate in the same queue processing.
61
+ - Size measurement runs from child to parent. Container measurements depend
62
+ on child dimensions, whether VerticalLayout sums them or BasicLayout unions bounds.
63
+ - Display-list updates run from parent to child. A parent first assigns child
64
+ layout bounds through setLayoutBoundsSize/setLayoutBoundsPosition; the child
65
+ can then finalize its display using the allocated bounds.
66
+
67
+ DepthQueue.shift() tracks the shallowest bucket with `_min`; pop() tracks the
68
+ deepest with `_max`. Removing clients advances these cursors rather than scanning
69
+ from the beginning on each call. New insertions can adjust the bounds again.
70
+
71
+ ### 2.2 Restarting after a later invalidation
72
+
73
+ Separate phases create a dependency problem: processing one client can invalidate
74
+ another client whose earlier phase has already run. A single pass may leave such
75
+ an invalidation pending even though it belongs to the same logical update.
76
+
77
+ `validateClient(target)` handles this for forced synchronous validation, such as
78
+ re-adding a removed component to the stage or calling component.validateNow():
79
+
80
+ 1. Set `_targetLevel` to target.$nestLevel when it is currently Infinity. Nested
81
+ calls retain the existing level and restore it afterward.
82
+ 2. Run an outer `while (!done)` loop:
83
+ - Remove clients from the target subtree's properties queue from shallow to
84
+ deep and call validateProperties. Check obj.stage before each call to skip
85
+ objects removed from the stage during validation.
86
+ - Process the subtree's size queue from deep to shallow. If measurement sets
87
+ `_clientPropsFlag` and a pending property client exists in the subtree,
88
+ reinsert that client, set done=false and break. The outer loop restarts at
89
+ properties before proceeding to display-list updates.
90
+ - Apply the same restart logic in the display-list phase for both
91
+ `_clientPropsFlag` and `_clientSizeFlag`.
92
+ 3. In finally, restore `_targetLevel`, synchronize empty-queue flags and clear
93
+ client flags. Validator.test.ts covers consistency after a client throws.
94
+ 4. Client flags are raised when an invalidated client's depth is at least
95
+ `_targetLevel`. Queue removal then checks actual ancestry to select clients
96
+ in the target subtree. Outside validateClient, `_targetLevel` is Infinity,
97
+ so ordinary clients do not raise these flags.
98
+
99
+ The deferred path, `_schedule()` → `_flush()`, is simpler: it runs properties,
100
+ size and display-list queues once each across the tree, using shift/pop, without
101
+ the synchronous restart loop. If flags remain afterward, it schedules another
102
+ callLater. Cascading invalidations can therefore require later deferred flushes;
103
+ validateClient instead converges synchronously for its target subtree.
104
+
105
+ ### 2.3 Ordering before rendering
106
+
107
+ `_schedule()` uses Core's ticker.callLater() to schedule `_flush()`. Core executes
108
+ flushCallLaters() before Player.render(), so initial property commits, measurement
109
+ and layout run before newly mounted components are drawn. This provides the
110
+ render-preparation ordering that Egret's Validator establishes through
111
+ Event.RENDER, without independent UI and renderer RAF loops competing for order.
116
112
 
117
113
  ---
118
114
 
119
- ## 三、皮肤系统
115
+ ## 3. Skin system
120
116
 
121
- ### 3.1 Skin 不是显示对象——这是相对 Egret EUI 最大的结构性差异
117
+ ### 3.1 Skin is a data object
122
118
 
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` 对象上。
119
+ `components/Skin.ts` defines Skin as an EventDispatcher<SkinEvents>, rather than
120
+ a display object. Egret EUI's Skin also extends EventDispatcher; its nonvisual
121
+ role is shared with Kurot.
130
122
 
131
- 这个设计选择的动机是让 `Skin` 保持纯粹的"声明式配置对象"角色,跟视觉
132
- 渲染完全脱钩,`Component` 才是唯一的显示树节点。
123
+ Skin declares skinParts (names), states (State entries) and elementsContent
124
+ (display objects to attach). Component._setSkin() performs the attachment:
125
+ elementsContent children are added directly to the host Component, and Skin
126
+ itself never appears in the display tree. This keeps the skin's configuration
127
+ and state role separate from the Component's visual-host role.
133
128
 
134
- ### 3.2 主题加载的完整链路
129
+ ### 3.2 Theme loading
135
130
 
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
131
+ ```text
132
+ new Theme(url)
133
+ → setTheme(this) synchronously register before loading completes
134
+ → _load(url) adapter.getTheme(url, onSuccess, onError)
135
+ → _onConfigLoaded(raw) parse JSON and merge skins/styles
136
+ → if skinsJs is present
137
+ → _loadSkinsModule() dynamically import compiled skins
138
+ → _onLoaded() mark initialized, handle _delayList, emit COMPLETE
139
+ → failure → _onLoadFailed() mark initialized and handle _delayList,
140
+ then emit IOErrorEvent.IO_ERROR
146
141
  ```
147
142
 
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 容易只改一边漏另一边。
143
+ Theme.ts uses an inline `_defaultThemeAdapter` backed by XMLHttpRequest unless
144
+ a custom adapter is passed to the constructor. The separately exported
145
+ DefaultThemeAdapter in `core/DefaultThemeAdapter.ts` uses fetch and is activated
146
+ only when the caller creates and supplies it. These two implementations do not
147
+ share loading logic, so changes to default-adapter behavior must account for
148
+ both paths.
156
149
 
157
- ### 3.3 编译期产物如何在运行时被找到——globalThis 是事实上的皮肤注册表
150
+ ### 3.3 Compiled skin factories and global registration
158
151
 
159
- `@kurot/cli` 编译 KUI XML Skin 时,为每个主题生成一个索引模块,做的事情等价于:
152
+ When CLI compiles KUI XML skins, its theme index module performs registrations
153
+ equivalent to:
160
154
 
161
155
  ```js
162
156
  import { createButtonSkin as s0 } from './skin0.js';
163
157
  globalThis['skins.ButtonSkin'] = s0;
164
158
  ```
165
159
 
166
- `Theme._loadSkinsModule()` 相对主题配置文件自身的 URL 解析出
167
- `skinsJs` 路径,动态 `import()` 这个模块。因为 ESM 模块求值是自顶向下
168
- 同步执行的,`import()` 的 Promise resolve 的那一刻,这个 bundle 里的每个
169
- 皮肤 factory 已经把自己挂到 `globalThis` 上了。
160
+ Theme._loadSkinsModule() resolves skinsJs relative to the theme configuration URL
161
+ and dynamically imports it. By the time that import promise resolves, the module
162
+ has registered its skin factories on globalThis.
170
163
 
171
- 真正的读取发生在后面,`Component._parseSkinName()` 处理字符串形式的
172
- `skinName` 时做 `(globalThis as Record<string, unknown>)[skinName]`。
173
- 所以完整链路是:
164
+ Component._parseSkinName() resolves a string skinName through
165
+ `(globalThis as Record<string, unknown>)[skinName]`. The complete flow is:
174
166
 
175
- ```
176
- CLI 编译期 globalThis["skins.X"] = factory (import 副作用)
177
- Theme 配置文件 hostComponentKey → "skins.X" (字符串映射)
178
- Component 赋皮肤时 globalThis["skins.X"] (读取拿到 factory)
167
+ ```text
168
+ CLI-generated module globalThis["skins.X"] = factory import side effect
169
+ Theme configuration hostComponentKey → "skins.X" string mapping
170
+ Component skin setup globalThis["skins.X"] factory lookup
179
171
  ```
180
172
 
181
- 没有独立的注册表对象——`globalThis` 本身就是皮肤 factory 的注册表。这是
182
- 刻意换来简单性的设计,代价是**皮肤类名是一个事实上的全局命名空间**:两个
183
- 主题如果凑巧用了同名的皮肤类(比如都叫 `"skins.ButtonSkin"`),后加载的
184
- 会直接覆盖前一个在 `globalThis` 上的槏位。
173
+ There is no separate registry object. Skin names occupy a global namespace:
174
+ if two themes use the same name, such as skins.ButtonSkin, the later-loaded
175
+ factory overwrites the earlier registration.
185
176
 
186
- ### 3.4 组件创建早于主题加载完成——`_delayList` 机制
177
+ ### 3.4 Components created before the theme is ready
187
178
 
188
- 游戏代码经常需要在网络请求(拉取主题配置)完成之前就先把 UI 树搭好。
189
- `Component.createChildren()` 如果没有显式设置 `skinName`,会去问全局
190
- `Theme` 要一个默认皮肤名:
179
+ Game code can construct the UI tree before the theme request finishes.
180
+ If Component.createChildren() has no explicit skinName, it asks the global
181
+ Theme for a default:
191
182
 
192
- - 如果主题还没初始化完(`!theme._initialized`),`Theme.getSkinName()`
193
- 把这个组件塞进 `_delayList`(去重),当下返回空字符串——**组件先以
194
- 完全没有皮肤的状态创建出来**。
195
- - 主题加载完成(成功或失败都算)后,`_handleDelayList()` 遍历这个队列:
196
- 对每个还没被用户手动设置过 `skinName` 的组件,重新解析出真正的皮肤名,
197
- 调用 `component._applySkinName()` 补上皮肤,触发一次重新验证。
183
+ - While the theme is uninitialized, getSkinName() puts the component into the
184
+ deduplicated `_delayList` and returns an empty string. The component starts
185
+ without a skin.
186
+ - On loading completion or failure, `_handleDelayList()` revisits components
187
+ whose skinName has not been set explicitly. It resolves available skin names
188
+ and calls component._applySkinName(), causing validation of the applied skin.
198
189
 
199
- 净效果:早创建的组件会有一到几帧渲染成"裸组件",主题加载完(含 skinsJs
200
- 模块的动态 import)之后被追加皮肤——这是让引擎能够异步解耦主题加载和
201
- UI 树构建的机制,跟 Egret 的异步主题加载惯例是一致的。
190
+ An early-created component can render without its skin until theme loading,
191
+ including the skinsJs import, finishes. This follows EUI's asynchronous-theme
192
+ pattern and decouples UI-tree construction from theme loading.
202
193
 
203
- ### 3.5 `_setSkin` 的拆装流程
194
+ ### 3.5 Replacing a skin
204
195
 
205
- `_parseSkinName()` 根据 `skinName` 的类型分派:函数 → 直接当 factory 调;
206
- 字符串 → 先查 `globalThis` 再当 factory 调;对象 → 直接当 `Skin` 实例用。
196
+ _parseSkinName() dispatches by skinName type: a function is invoked as a factory
197
+ or constructor; a string is resolved through globalThis; an object is used as
198
+ a Skin instance.
207
199
 
208
- `_invokeSkinFactory()` 用正则 `/^class\s/.test(Function.prototype.toString.call(fn))`
209
- 区分真正的 ES class 构造器(用 `new` 调)和 KUI XML 编译产生的 factory 函数(用 `fn.call(this)` 调,让 factory 获得宿主组件上下文)。
200
+ _invokeSkinFactory() tests `/^class\s/.test(Function.prototype.toString.call(fn))`
201
+ to distinguish ES class constructors, invoked with new, from compiled KUI
202
+ factories, invoked with fn.call(this) to supply the host context.
210
203
 
211
- `_setSkin(skin)` 做的事:
204
+ _setSkin(skin) follows this lifecycle:
212
205
 
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`。
206
+ 1. Detach the old skin: call onSkinRemoved() while its complete skinParts map
207
+ remains available, mark it unready and clear the map, set
208
+ oldSkin.hostComponent=undefined to exit its state, call oldSkin.unwatchAll()
209
+ to remove Watchers, and detach elementsContent from the host.
210
+ 2. Attach the new skin: collect declared parts through skin.getPart(name) into
211
+ the internal map, add elementsContent to the host in declaration order,
212
+ set currentState and hostComponent, mark the skin ready and call onSkinReady().
213
+ The Component instance does not acquire dynamic properties for those part names.
214
+ 3. Invalidate size and display list, then dispatch Event.COMPLETE on the Component.
224
215
 
225
216
  ---
226
217
 
227
- ## 四、视图状态(State/Override 系统)
218
+ ## 4. View states and overrides
228
219
 
229
- ### 4.1 状态切换的实际算法——没有真正的"diff"
220
+ ### 4.1 State transitions remove and reapply overrides
230
221
 
231
- `IOverride` 只有两个方法:`apply(host, skin)`、`remove(host, skin)`。
232
- `Skin._applyState(fromState, toState)` 的实现是:
222
+ IOverride defines apply(host, skin) and remove(host, skin).
223
+ Skin._applyState(fromState, toState) follows this algorithm:
233
224
 
234
- ```
225
+ ```text
235
226
  oldState = states.find(s => s.name === fromState)
236
- 若存在:对 oldState.overrides 逐个调用 override.remove(host, this)
227
+ if present: call override.remove(host, this) for every oldState override
237
228
 
238
229
  newState = states.find(s => s.name === toState)
239
- 若存在:对 newState.overrides 逐个调用 override.apply(host, this)
230
+ if present: call override.apply(host, this) for every newState override
240
231
  ```
241
232
 
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
- 未做这个抽取,属于已知但尚未处理的技术债,不是刻意的设计。
233
+ It does not compute the difference between override lists. Even a shared override
234
+ instance is removed and reapplied. SetProperty/SetStateProperty may restore a
235
+ value and immediately set it again. AddItems can remove and reinsert an object,
236
+ with possible z-order changes unless its placement is fully specified.
237
+ Developers expecting only changed overrides to execute must account for this.
238
+
239
+ ### 4.2 Override behavior
240
+
241
+ - SetProperty targets a skin part, or Skin itself for an empty target. apply()
242
+ saves `_oldValue` and assigns the new value. remove() restores it only when
243
+ `_applied` is true.
244
+ - SetStateProperty uses the same save/restore pattern on the host Component,
245
+ through host[name]=value. It changes public host behavior, such as enabled,
246
+ rather than a named skin part.
247
+ - AddItems moves a display object into a destination container, defaulting to the
248
+ host, at the specified position. remove() detaches it only if item.parent===dest,
249
+ preserving an object that other logic has since moved elsewhere.
250
+
251
+ ### 4.3 Separate Skin and Group state machines
252
+
253
+ Group has no attached Skin, so `_commitCurrentState()` implements similar
254
+ apply/remove logic using `_states`, `_statesMap` and `_currentState`.
255
+ It passes Group as both roles using
256
+ `override.apply(this as unknown as Component, this as unknown as Skin)`.
257
+ The signature assumes separate Component and Skin objects, while Group has only
258
+ itself. Host-property overrides can use this arrangement; code that requires
259
+ real Skin APIs such as getPart() must not assume that Group provides them.
260
+
261
+ The duplicated transition logic and casts are a known coupling point. A shared
262
+ transition helper could reduce the duplication, but no such extraction is
263
+ currently implemented.
281
264
 
282
265
  ---
283
266
 
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
- 布局类不要假设覆写它就能拿到虚拟可见范围。
267
+ ## 5. Layout system
268
+
269
+ ### 5.1 Measurement and display-list contracts
270
+
271
+ LayoutBase defines the two operations implemented by concrete layouts:
272
+
273
+ - measure() runs from deep to shallow. It uses each child's getPreferredBounds()
274
+ to determine the container's preferred dimensions and reports them through
275
+ target.setMeasuredSize(w, h).
276
+ - updateDisplayList(width, height) runs from shallow to deep, receiving allocated
277
+ dimensions that can differ from measured dimensions because of parent
278
+ constraints. It assigns participating children through setLayoutBoundsSize()
279
+ and setLayoutBoundsPosition(), then reports occupied dimensions with
280
+ target.setContentSize(w, h) for scrolling/viewport calculations.
281
+
282
+ ILayoutTarget is the minimal container interface used by layout algorithms:
283
+ numChildren, getChildAt, scrollH, setMeasuredSize and related operations.
284
+ Group and Component both implement it. Layouts such as BasicLayout and
285
+ VerticalLayout can be assigned to Group.layout; Component internally reuses a
286
+ shared BasicLayout for measurement and display-list updates by temporarily
287
+ setting its target. Component's skin measurement uses unscaled preferred bounds,
288
+ so the host's transform is applied by its parent rather than counted twice.
289
+
290
+ Layouts skip children that do not implement the UI layout contract or have
291
+ includeInLayout=false. This excludes an element from layout without requiring
292
+ its removal from the display tree. State membership that changes display-tree
293
+ attachment is handled separately by AddItems.
294
+
295
+ ### 5.2 Percentage dimensions
296
+
297
+ BasicLayout resolves left/right/top/bottom, horizontalCenter/verticalCenter and
298
+ percentWidth/percentHeight. When both edge constraints on an axis are set, they
299
+ fix that dimension: width becomes unscaledWidth-right-left, taking precedence
300
+ over percentWidth. Percentage sizing applies when the two edge constraints do
301
+ not both determine the dimension.
302
+
303
+ The fmt() helper parses string constraints such as `left="10%"` in KUI XML.
304
+ A trailing percent sign selects percentage calculation; otherwise the value is
305
+ converted to a number. There is no separate runtime type tag for percentages
306
+ versus pixels.
307
+
308
+ LinearLayoutBase.flexChildrenProportionally(), shared by VerticalLayout and
309
+ HorizontalLayout, iteratively allocates percentages with min/max constraints.
310
+ A do...while loop identifies children whose proportional allocation exceeds a
311
+ bound, clamps them and removes them from the remaining allocation pool using
312
+ in-place partition swaps. It subtracts their percentage shares and redistributes
313
+ the remainder. One division is insufficient because clamping a child changes
314
+ the effective shares of the others. This follows the Flex-style percentage
315
+ allocation algorithm.
316
+
317
+ ### 5.3 Gaps and padding
318
+
319
+ LinearLayoutBase owns gap and paddingLeft/Right/Top/Bottom; setters invalidate
320
+ the target layout. VerticalLayout/HorizontalLayout subtract
321
+ (numElements-1)*gap before allocation, then add gaps during positioning.
322
+ TileLayout maintains separate horizontalGap/verticalGap and padding fields.
323
+ It extends LayoutBase directly, so these accessors are implemented independently
324
+ of LinearLayoutBase.
325
+
326
+ ### 5.4 Virtual layout
327
+
328
+ Virtual layout is disabled by default. Enable it explicitly through
329
+ layout.useVirtualLayout or the corresponding DataGroup/List property.
330
+ It lets List/DataGroup instantiate renderers only for the visible range when
331
+ handling thousands of records:
332
+
333
+ - LinearLayoutBase's protected elementSizeTable stores the last known axis size
334
+ by data index, rather than display-child index. NaN means unknown, estimated
335
+ using typicalWidth/typicalHeight, initially 71 × 22.
336
+ - elementAdded(index)/elementRemoved(index) keep the size table synchronized with
337
+ ICollection changes without requiring full remeasurement.
338
+ - getStartPosition(index) accumulates preceding sizes to obtain an offset.
339
+ findIndexAt uses recursive binary search to map scroll positions to visible indices.
340
+ - updateDisplayListVirtual() visits only startIndex..endIndex. It calls
341
+ target.setVirtualElementIndicesInView(startIndex, endIndex), which lets
342
+ DataGroup recycle and instantiate renderers for that range.
343
+ - measureVirtual() estimates total scrollable size from getElementTotalSize()
344
+ and a correction based on visible elements, without instantiating every
345
+ off-screen record.
346
+ - TileLayout uses scrollH/scrollV and fixed column/row strides, including gaps,
347
+ to determine the visible range through _getIndexInView(). Uniform cells make
348
+ this simpler than variable-size linear virtualization.
349
+
350
+ LayoutBase.getElementIndicesInView() returns an empty array by default.
351
+ TileLayout overrides it to expose the currently calculated range.
352
+ VerticalLayout/HorizontalLayout use protected getIndexInView() methods for their
353
+ internal calculations and do not override that public query. New layouts must
354
+ explicitly connect range calculation, viewport invalidation and renderer
355
+ materialization; overriding the public query alone does not drive virtual layout.
372
356
 
373
357
  ---
374
358
 
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` 驱动。 |
359
+ ## 6. Component hierarchy
360
+
361
+ ### Component subclasses
362
+
363
+ Component is the visual host for skinnable controls, delegating skin states to
364
+ Skin and layout/validation state to UIState. The table lists its descendants,
365
+ including intermediate base classes.
366
+
367
+ | Class | Responsibility |
368
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------- |
369
+ | `Component` | Base for skinnable components: skin lifecycle, view states, constraints and validation delegation |
370
+ | `Rect` | Rectangle graphics with fill and stroke |
371
+ | `Image` | Displays bitmap data resolved through IAssetAdapter |
372
+ | `Label` | Text display wrapping one TextField |
373
+ | `BitmapLabel` | Bitmap-font label wrapping one BitmapText; requires Core 2.2.0 or later |
374
+ | `EditableText extends Label` | Editable text with prompt support |
375
+ | `Button` | Clickable up/down/disabled states; toggle enables automatic selected changes |
376
+ | `ToggleButton extends Button` | Defaults toggle to true and toggles selection on click |
377
+ | `CheckBox extends ToggleButton` | Toggle behavior whose appearance is defined by its skin |
378
+ | `RadioButton extends ToggleButton` | Mutually exclusive selection through RadioButtonGroup |
379
+ | `ToggleSwitch extends ToggleButton` | Skin variant presenting a sliding switch |
380
+ | `Range` | Bounds values to minimum/maximum with optional snapInterval; base for sliders |
381
+ | `SliderBase extends Range` | Abstract dragging behavior for thumb/track skin parts |
382
+ | `HSlider extends SliderBase` | Horizontal slider, increasing left to right |
383
+ | `VSlider extends SliderBase` | Vertical slider, increasing bottom to top |
384
+ | `ScrollBarBase` | Binds thumb movement to a viewport's scroll position |
385
+ | `HScrollBar extends ScrollBarBase` | Horizontal scrolling through scrollH |
386
+ | `VScrollBar extends ScrollBarBase` | Vertical scrolling through scrollV |
387
+ | `ProgressBar` | Displays progress through a clipped thumb using scrollRect, with label formatting |
388
+ | `TextInput` | Text input with prompts and password masking |
389
+ | `Panel` | Skinnable container with optional title bar, close button and drag area |
390
+ | `Scroller` | Wraps an IViewport, usually Group, and manages touch scrolling and scrollbars |
391
+ | `ComboBox` | Dropdown selection with a trigger button and popup list |
392
+ | `ItemRenderer` | Data-driven item renderer base for DataGroup/List/TabBar |
393
+
394
+ ### Group subclasses
395
+
396
+ Group is a container without a skin. It owns a lightweight state machine and a
397
+ pluggable LayoutBase.
398
+
399
+ | Class | Responsibility |
400
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
401
+ | `Group` | Participates in validation and delegates child positioning to LayoutBase; maintains its own state machine, as described in section 4.3 |
402
+ | `DataGroup extends Group` | Converts ICollection records into renderers and maintains a reusable pool for virtual layout |
403
+ | `ListBase extends DataGroup` | Adds selectedIndex/selectedItem and requireSelection semantics |
404
+ | `List extends ListBase` | Adds touch selection by clicking renderers |
405
+ | `TabBar extends ListBase` | Selectable tabs with requireSelection=true by default |
406
+ | `ViewStack` | Stacked navigation container showing one child at a time; implements ICollection |
407
+ | `UILayer` | Top-level container following stage dimensions through RESIZE events |
408
+
409
+ ### Supporting classes
410
+
411
+ | Class | Responsibility |
412
+ | ------------------ | ----------------------------------------------------------------------------------------------------- |
413
+ | `Skin` | Nonvisual EventDispatcher declaring skinParts/states/elementsContent; see section 3 |
414
+ | `RadioButtonGroup` | EventDispatcher tracking mutually exclusive RadioButtons under a groupName and emitting PropertyEvent |
415
+ | `TouchScroll` | Inertial touch-scroll physics used by Scroller |
416
+ | `Animation` | Numeric easing used by TouchScroll, driven by Core's ticker |
425
417
 
426
418
  ---
427
419
 
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
- "皮肤即容器"模型的刻意偏离。
420
+ ## 7. Relationship to Egret EUI
421
+
422
+ | Egret EUI concept | Kurot UI counterpart | Notes |
423
+ | ------------------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
424
+ | `eui.Component` | `Component` | Same role; UI behavior uses standard Sprite inheritance and explicit delegation rather than prototype mixins |
425
+ | `eui.Skin` | `Skin` | Both are nonvisual EventDispatcher objects; their content is attached to the host component |
426
+ | Three-phase UIComponent validation | `UIState` + `Validator` | Depth-ordered properties, measurement and display-list phases; Kurot uses Core's callLater queue before rendering where Egret uses Event.RENDER |
427
+ | Theme adapters and theme registration | `IThemeAdapter` + `Theme`/`getTheme()`/`setTheme()` | Both support theme adapters; Kurot injects the adapter through the Theme constructor, while the Egret reference resolves eui.IThemeAdapter through its implementation registry |
428
+ | Global registration of compiled EXML skins | `globalThis["skins.X"] = factory` from ESM imports | Both use global skin names; Kurot's compiled output registers factories invoked with .call(this) to provide host context |
429
+ | State/SetProperty/SetStateProperty/AddItems | Same class names and IOverride interface | Direct adaptation of declarative state overrides |
430
+ | BasicLayout/VerticalLayout/HorizontalLayout/TileLayout | Same class names | Similar percentage allocation, gaps, padding and virtual-layout algorithms, including iterative min/max clamping |
431
+ | Prototype-copy UIComponent implementation | TypeScript inheritance + UIState/ILayoutTarget delegation | The central architectural change described in section 1 |
432
+
433
+ Kurot-specific choices include the IUIOwner/UIState composition boundary,
434
+ constructor-based Theme adapter injection and ESM skin factories with explicit
435
+ host context. UIState can exist independently of a specific DisplayObject
436
+ subclass; Group and Component each hold an instance rather than requiring a
437
+ shared visual base class for all validation state.
452
438
 
453
439
  ---
454
440
 
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 框架排名。
441
+ ## 8. Test coverage
442
+
443
+ Tests are grouped under `test/`. Run `pnpm --dir packages/ui test` for current
444
+ file and case counts.
445
+
446
+ | Area | Test files |
447
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
448
+ | Validation / theme infrastructure | sanity, Theme, Validator |
449
+ | Skins and states | Skin, SkinAlignment (real CLI template skins), SkinMeasurement, AddItems |
450
+ | Data binding | Binding, Watcher |
451
+ | Collections and data-driven components | ArrayCollection, DataGroup, ListBase, ItemRenderer, ComboBox, TabBar, ViewStack |
452
+ | Individual components | RadioButton, Range, Slider, ProgressBar, Scroller, Label, BitmapLabel, TextInput |
453
+ | Text fitting | ButtonLabelTextFit, LabelTextFit, LabelTextFitBounds |
454
+ | Cross-component behavior | Enabled (EUI-aligned enabled/touchEnabled/touchChildren behavior), EventMapOverride, GestureLifecycle, TransformLayout, ScrollAndVirtualLayout |
455
+
456
+ There is no dedicated Group.test.ts; Group state behavior is covered indirectly
457
+ by container/component tests. There are also no separate VerticalLayout.test.ts
458
+ or TileLayout.test.ts files. Layout behavior is exercised by component and
459
+ integration tests such as TransformLayout, Scroller, SkinAlignment,
460
+ SkinMeasurement and ScrollAndVirtualLayout. Dedicated state-machine and layout
461
+ algorithm tests remain useful areas for additional coverage.
462
+
463
+ `examples/benchmark/` provides real Chromium performance checks for a 400-node
464
+ static UI, 240-node transform/alpha animation and a virtual list with 10,000 records.
465
+ It records frame/render time, draw calls, validation-phase calls and ItemRenderer
466
+ creation/reuse. It is a regression baseline for this UI implementation, rather
467
+ than a ranking across UI frameworks.