461 lines
17 KiB
Markdown
461 lines
17 KiB
Markdown
# NewEditor PropertyGrid Input Routing Refactor Plan
|
||
|
||
Date: 2026-04-23
|
||
Status: Planned
|
||
|
||
## 1. Objective
|
||
|
||
彻底解决 `new_editor` Inspector PropertyGrid 中由共享输入状态污染导致的交互异常,重点包括但不限于:
|
||
|
||
1. `Color` 字段关闭颜色选择器后首击失效、需要双击、甚至双击也无反应。
|
||
2. 不同字段类型之间切换时,前一个字段的焦点、按下态、编辑态污染后一个字段。
|
||
3. `PropertyGrid` 内部同样表现为“看起来都是按钮/控件”,但行为不一致,`Enum`、`Color`、`Bool`、`Asset`、`Number/Text/Vector` 各走各的隐式输入规则。
|
||
|
||
这次不是做症状修补,而是把 `PropertyGrid` 的输入架构改成单目标路由和明确所有权模型,从根上消掉这类首击被吃、状态串扰、字段间抢状态的问题。
|
||
|
||
## 2. Confirmed Root Cause
|
||
|
||
当前问题不是颜色选择器窗口生命周期本身导致的,而是 `PropertyGrid` 输入分发架构有设计缺陷。
|
||
|
||
已确认的根因如下:
|
||
|
||
1. `UpdateUIEditorPropertyGridInteraction(...)` 会把同一个事件依次送进多套字段处理逻辑:
|
||
- `ProcessNumberFieldEvent(...)`
|
||
- `ProcessTextFieldEvent(...)`
|
||
- `ProcessColorFieldEvent(...)`
|
||
- `ProcessVector*FieldEvent(...)`
|
||
- `ProcessAssetFieldEvent(...)`
|
||
见 `new_editor/src/Fields/UIEditorPropertyGridInteraction.cpp`
|
||
2. `Number/Text/Vector` 的共享处理器内部不是只处理“当前命中的那个字段”,而是遍历所有可见字段,并把 `hadFocus`、`focused` 也视为本次事件的有效证据。
|
||
3. 共享编辑会话 `editableFieldSession` 会被拷入每个字段的局部交互状态,再被某个字段处理器写回全局,导致一次事件期间多个字段都可能改写共享状态。
|
||
4. `pressedFieldId` 是 `PropertyGrid` 级别的共享按下态,但多个字段处理器会在处理中途清掉它。
|
||
5. `Color` 字段打开颜色选择器依赖“两阶段命中”:
|
||
- `PointerButtonDown` 时 `pressedFieldId = 当前 color 字段`
|
||
- `PointerButtonUp` 时再次确认 `pressedFieldId` 仍然是该字段
|
||
所以它对共享按下态污染最敏感。
|
||
6. `Enum` 下拉菜单则不同,它在 `PropertyGrid` 的通用 `PointerButtonUp` 路径里直接根据当前命中结果 `OpenPopup(...)`,不依赖 `Color` 那套专门的 armed/确认链路,所以表面上表现更稳定。
|
||
|
||
结论是:当前 bug 的根不是 `ColorPicker`,而是 `PropertyGrid` 自身没有明确的输入 owner,导致事件广播、共享状态回写、字段局部状态和全局状态互相污染。
|
||
|
||
## 3. Problem Statement
|
||
|
||
当前架构同时踩中了下面几个反模式:
|
||
|
||
1. 广播式分发:一个事件被多套字段逻辑重复消费。
|
||
2. 共享可变状态:`pressedFieldId`、`editableFieldSession`、`propertyGridState.focused` 由多个处理器写入。
|
||
3. 旧焦点兜底:把 `hadFocus`、`focused` 这种陈旧状态当作本次点击的直接交互证据。
|
||
4. 路由和行为耦合:`PropertyGrid` 既负责决定事件给谁,又让字段处理器反过来改全局路由状态。
|
||
5. 字段类别缺少统一抽象:`Color` 和 `Enum` 都像“动作型控件”,但输入语义却不统一。
|
||
|
||
如果继续在现状上加特判,只会形成新的状态分叉,后面还会在 `Asset`、`Bool`、`Vector`、键盘导航、失焦恢复上继续炸。
|
||
|
||
## 4. Target End State
|
||
|
||
重构完成后,`PropertyGrid` 必须满足下面这些结构性约束:
|
||
|
||
1. 每个输入事件在进入字段处理前,先由 `PropertyGrid` 路由层解析出唯一目标。
|
||
2. 任意时刻只有一个字段拥有“按下态”。
|
||
3. 任意时刻只有一个字段拥有“活动编辑态”。
|
||
4. 任意时刻只有一个字段拥有“弹出层所有权”。
|
||
5. 字段处理器不再直接清理或重置 `PropertyGrid` 的共享路由状态。
|
||
6. `Selection`、`Focus`、`Armed`、`ActiveEdit`、`PopupOwner` 是不同概念,不能再混用。
|
||
7. `Color`、`Enum`、`Bool`、`Asset` 这类动作型字段遵循统一输入契约。
|
||
8. `Number`、`Text`、`Vector2/3/4` 这类内联编辑字段遵循统一输入契约。
|
||
9. 关闭颜色选择器、关闭枚举下拉、提交数值编辑、切换字段后,下一次首击必须稳定命中目标控件。
|
||
|
||
## 5. Architectural Decision
|
||
|
||
### 5.1 引入单目标路由层
|
||
|
||
`PropertyGrid` 必须先做一次统一 hit test 和 owner 解析,再决定这次事件只派发给谁。不能继续让每类字段都自己遍历所有字段再“看看自己是不是该处理”。
|
||
|
||
路由层需要先得到:
|
||
|
||
1. `hoveredFieldId`
|
||
2. `hoveredPart`
|
||
3. `armedFieldId`
|
||
4. `capturedFieldId`
|
||
5. `activeEditFieldId`
|
||
6. `popupFieldId`
|
||
|
||
然后按事件类型走固定路由规则:
|
||
|
||
1. `PointerMove` 只更新 hover,以及发给 `capturedFieldId` 或当前 hover 字段。
|
||
2. `PointerButtonDown` 只发给当前命中字段,并决定是否设置 `armedFieldId` / `capturedFieldId`。
|
||
3. `PointerButtonUp` 只发给 `armedFieldId` 或 `capturedFieldId`,而不是再次广播给所有字段。
|
||
4. `KeyDown` / `TextInput` 只发给 `activeEditFieldId` 或 `popupFieldId`。
|
||
5. `FocusLost` 由路由层统一处理清理顺序,而不是让每个字段各自猜测。
|
||
|
||
### 5.2 引入明确的输入所有权状态
|
||
|
||
现有状态过于粗糙,后续需要拆成下面几个正交状态:
|
||
|
||
1. `gridFocused`
|
||
2. `hoveredFieldId`
|
||
3. `hoveredPart`
|
||
4. `armedFieldId`
|
||
5. `capturedFieldId`
|
||
6. `activeEditFieldId`
|
||
7. `popupFieldId`
|
||
8. `popupHighlightedIndex`
|
||
|
||
原则:
|
||
|
||
1. `selectionModel` 只表示“谁被选中”。
|
||
2. `gridFocused` 只表示“PropertyGrid 是否拥有键盘焦点”。
|
||
3. `armedFieldId` 只表示“谁接收对应的抬起事件”。
|
||
4. `activeEditFieldId` 只表示“谁接收文本编辑和编辑提交”。
|
||
5. `popupFieldId` 只表示“谁拥有弹出层”。
|
||
|
||
任何字段逻辑都不应再拿 `selected == true` 或 `focused == true` 推断“这次点击就是我的”。
|
||
|
||
### 5.3 统一字段交互类别
|
||
|
||
后续要把字段分成两类,而不是继续按“字段类型代码文件”来决定输入语义。
|
||
|
||
#### A. Action Control
|
||
|
||
适用字段:
|
||
|
||
1. `Color`
|
||
2. `Enum`
|
||
3. `Bool`
|
||
4. `Asset`
|
||
|
||
统一规则:
|
||
|
||
1. 点击命中当前控件时由路由层设置 `armedFieldId`。
|
||
2. 抬起时若仍满足激活条件,则执行动作。
|
||
3. 动作可能是:
|
||
- 打开/关闭 popup
|
||
- 请求外部 picker
|
||
- toggle bool
|
||
- activate asset picker
|
||
4. 动作执行前由路由层统一决定是否提交其他活动编辑。
|
||
|
||
#### B. Inline Editor
|
||
|
||
适用字段:
|
||
|
||
1. `Number`
|
||
2. `Text`
|
||
3. `Vector2`
|
||
4. `Vector3`
|
||
5. `Vector4`
|
||
|
||
统一规则:
|
||
|
||
1. 点击命中值域时由路由层激活编辑或拖拽。
|
||
2. 所有文本编辑态都只通过 `activeEditFieldId` 进入。
|
||
3. 提交、取消、切换字段时由路由层统一协调。
|
||
4. 不再通过 `hadFocus` 来“补判定”当前事件属于哪个字段。
|
||
|
||
## 6. State Refactor Plan
|
||
|
||
### 6.1 `UIEditorPropertyGridState`
|
||
|
||
需要把当前定义从“渲染态和交互 owner 混在一起”改成“路由态 + 视觉态”分离。
|
||
|
||
建议修改方向:
|
||
|
||
1. 保留:
|
||
- `hoveredSectionId`
|
||
- `hoveredFieldId`
|
||
- `hoveredHitTarget`
|
||
- `popupHighlightedIndex`
|
||
2. 替换:
|
||
- `focused` -> `gridFocused`
|
||
- `pressedFieldId` -> `armedFieldId`
|
||
3. 新增:
|
||
- `capturedFieldId`
|
||
- `activeEditFieldId`
|
||
- 视情况新增 `activeFieldPart`
|
||
4. 明确 `popupFieldId` 是 owner,而不是顺手存一份状态。
|
||
|
||
### 6.2 `UIEditorPropertyGridInteractionState`
|
||
|
||
当前 `editableFieldSession` 仍然是核心污染源之一。需要改成:
|
||
|
||
1. 全局状态只保留 owner id 和必要的共享编辑资源。
|
||
2. 局部字段会话只在 owner 命中时构造和更新。
|
||
3. 非 owner 字段不再拿到一份复制出来的共享编辑会话。
|
||
|
||
也就是说,后续不应继续存在“给所有字段都 BuildInteractionState,然后再把共享 session 混进去”的流程。
|
||
|
||
## 7. Routing Refactor Plan
|
||
|
||
### Phase A. 建立统一命中解析和 owner 解析
|
||
|
||
目标:
|
||
|
||
1. 在 `UpdateUIEditorPropertyGridInteraction(...)` 入口统一解析当前命中的 section / field / field part。
|
||
2. 明确当前事件是发给:
|
||
- hover target
|
||
- armed target
|
||
- captured target
|
||
- popup owner
|
||
- active editor
|
||
|
||
实施内容:
|
||
|
||
1. 抽出 `ResolveCurrentFieldTarget(...)`。
|
||
2. 抽出 `ResolveEventOwner(...)`。
|
||
3. 明确 `PointerButtonDown` 和 `PointerButtonUp` 的路由优先级。
|
||
|
||
完成标准:
|
||
|
||
1. 一个事件最多只进入一个字段 owner 的处理逻辑。
|
||
2. 非 owner 字段不会因为 `hadFocus` 或视觉状态变化而被视为“参与了这次交互”。
|
||
|
||
### Phase B. 把 Action Control 从共享广播链路中剥离
|
||
|
||
目标:
|
||
|
||
1. 让 `Color`、`Enum`、`Bool`、`Asset` 都走统一的 action-control owner 路由。
|
||
2. 消除 `Color` 与 `Enum` 当前“看起来都像按钮,但输入模型完全不同”的问题。
|
||
|
||
实施内容:
|
||
|
||
1. 新建统一的 action-control dispatch 层。
|
||
2. `Color` 不再依赖“先被别的字段处理器放过、再轮到我”的顺序偶然性。
|
||
3. `Enum` 的 popup 打开逻辑保留语义,但改为经由统一 owner 路由触发。
|
||
4. `Bool`、`Asset` 也并入相同的 click activation 契约。
|
||
|
||
完成标准:
|
||
|
||
1. `Color` 和 `Enum` 首击都依赖同一套 armed/release 判定语义。
|
||
2. `Action Control` 的 popup / picker / toggle 行为不再受其他字段旧焦点状态影响。
|
||
|
||
### Phase C. 把 Inline Editor 改成 owner-exclusive 编辑模型
|
||
|
||
目标:
|
||
|
||
1. `Number/Text/Vector` 只处理当前活动编辑字段。
|
||
2. 去掉 `hadFocus`、`focused` 参与当前事件归属判定。
|
||
|
||
实施内容:
|
||
|
||
1. 删掉共享模板处理中“旧焦点也算有意义事件”的判定。
|
||
2. `CommitActiveEdit(...)` 改为只在 owner 切换或路由层明确要求时触发。
|
||
3. `StoreInteractionState(...)` 不再承担全局 owner 切换职责。
|
||
4. 拖拽、文本输入、提交/取消都改成围绕 `activeEditFieldId` 运行。
|
||
|
||
完成标准:
|
||
|
||
1. 点 `Color` 时不会因为前一个 `Number`/`Vector` 字段曾经 focused 而被抢走首击。
|
||
2. 各内联编辑字段只在自己是 active owner 时接收编辑事件。
|
||
|
||
### Phase D. 统一 focus lost / popup close / picker return 的清理顺序
|
||
|
||
目标:
|
||
|
||
1. 让 `FocusLost`、utility window 关闭、popup 关闭后的首击行为稳定。
|
||
|
||
实施内容:
|
||
|
||
1. 路由层统一清理:
|
||
- `armedFieldId`
|
||
- `capturedFieldId`
|
||
- `activeEditFieldId`
|
||
- popup owner
|
||
2. 明确“关闭外部 picker”不会自动把旧字段恢复成 armed 状态。
|
||
3. 明确“返回 Inspector 后的第一下点击”必须重新从命中解析开始。
|
||
|
||
完成标准:
|
||
|
||
1. 关闭 `ColorPicker` 后直接单击 color swatch 即可再次打开。
|
||
2. 不需要先额外点一下面板来恢复交互。
|
||
|
||
## 8. File-Level Execution Plan
|
||
|
||
### 8.1 必改文件
|
||
|
||
1. `new_editor/include/XCEditor/Fields/UIEditorPropertyGrid.h`
|
||
2. `new_editor/include/XCEditor/Fields/UIEditorPropertyGridInteraction.h`
|
||
3. `new_editor/src/Fields/PropertyGridInteractionInternal.h`
|
||
4. `new_editor/src/Fields/UIEditorPropertyGridInteraction.cpp`
|
||
5. `new_editor/src/Fields/UIEditorPropertyGrid.cpp`
|
||
|
||
### 8.2 可能需要同步调整的字段交互文件
|
||
|
||
1. `new_editor/src/Fields/UIEditorColorFieldInteraction.cpp`
|
||
2. `new_editor/src/Fields/UIEditorNumberFieldInteraction.cpp`
|
||
3. `new_editor/src/Fields/UIEditorTextFieldInteraction.cpp`
|
||
4. `new_editor/src/Fields/UIEditorVectorFieldInteractionShared.h`
|
||
5. `new_editor/src/Fields/UIEditorAssetFieldInteraction.cpp`
|
||
|
||
### 8.3 原则上不该成为主战场的文件
|
||
|
||
1. `new_editor/app/Features/Inspector/InspectorPanel.cpp`
|
||
2. `new_editor/app/Platform/Win32/Windowing/EditorUtilityWindowCoordinator.cpp`
|
||
3. `new_editor/app/Platform/Win32/Chrome/EditorWindowChromeController.cpp`
|
||
|
||
这些文件最多做接口适配,不应该再承担修 PropertyGrid 首击丢失问题的核心逻辑。
|
||
|
||
## 9. Detailed Execution Steps
|
||
|
||
### Step 1. 先改状态模型,不碰行为
|
||
|
||
1. 在头文件里引入新的 owner 状态字段。
|
||
2. 保持现有逻辑先能编译,通过适配层把旧字段名映射到新字段名。
|
||
3. 明确哪些状态属于路由层,哪些属于视觉层。
|
||
|
||
交付物:
|
||
|
||
1. 新的状态结构。
|
||
2. 路由状态注释和使用约束。
|
||
|
||
### Step 2. 抽出统一命中和 owner 决策
|
||
|
||
1. 先把当前 scattered hit test 收拢到路由入口。
|
||
2. 让 `PointerDown` / `PointerUp` 都走同一套 owner 解析。
|
||
3. 暂时保留旧字段处理器,但只让 owner 字段收到事件。
|
||
|
||
交付物:
|
||
|
||
1. `ResolveCurrentFieldTarget(...)`
|
||
2. `ResolveEventOwner(...)`
|
||
|
||
### Step 3. 重写 Action Control 路由
|
||
|
||
1. 让 `Color` 和 `Enum` 共用 action-control 激活契约。
|
||
2. 再纳入 `Bool` 和 `Asset`。
|
||
3. 把“是否需要提交已有编辑”提升到路由层统一决定。
|
||
|
||
交付物:
|
||
|
||
1. `Action Control` 输入约束。
|
||
2. `Color` / `Enum` / `Bool` / `Asset` 的统一 owner 生命周期。
|
||
|
||
### Step 4. 重写 Inline Editor 路由
|
||
|
||
1. 删除基于 `hadFocus` 的事件归属判定。
|
||
2. 只在 owner 切换时提交旧编辑。
|
||
3. 把 vector 组件拖拽和文本输入纳入统一 owner-exclusive 逻辑。
|
||
|
||
交付物:
|
||
|
||
1. 无 `hadFocus` 兜底判定的 `Inline Editor` 路由。
|
||
2. 统一的 commit / cancel / drag 生命周期。
|
||
|
||
### Step 5. 统一外部 popup / picker 返回后的恢复行为
|
||
|
||
1. 把 `FocusLost` 和 popup close 的清理顺序固定下来。
|
||
2. 做 color picker 返回、enum popup 切换、asset picker 返回等场景的首击验证。
|
||
|
||
交付物:
|
||
|
||
1. 稳定的失焦恢复行为。
|
||
2. 不依赖“双击补救”的交互语义。
|
||
|
||
### Step 6. 清理旧状态分支和垃圾代码
|
||
|
||
1. 删除只为旧架构兜底的 `hadFocus`、共享 pressed reset、补丁式焦点同步逻辑。
|
||
2. 清理无主状态变量和死分支。
|
||
3. 补充必要注释,说明 owner 规则。
|
||
|
||
交付物:
|
||
|
||
1. 干净的主线逻辑。
|
||
2. 可维护的输入架构。
|
||
|
||
## 10. Red Lines
|
||
|
||
这次重构不能做下面这些事:
|
||
|
||
1. 不能通过在 `InspectorPanel` 外层加 reset 或“返回时强制清状态”来掩盖问题。
|
||
2. 不能依赖日志、延迟、双击判定、窗口 reopen、焦点强抢来修症状。
|
||
3. 不能在 `Color` 专门加一堆例外分支,而不处理共享输入路由本身。
|
||
4. 不能回退成“所有字段都当普通按钮”而破坏现有数值拖拽、文本编辑、键盘导航。
|
||
5. 不能继续把 `selected`、`focused`、`pressed`、`editing` 当成同一种状态使用。
|
||
|
||
## 11. Validation Matrix
|
||
|
||
### 11.1 Color
|
||
|
||
1. 点击 `Color` 首次打开 picker。
|
||
2. 关闭 picker 后直接再次点击同一 `Color` 首次打开。
|
||
3. 先编辑 `Number`,再点 `Color`,首击打开。
|
||
4. 先拖 `Vector`,再点 `Color`,首击打开。
|
||
5. 切换不同组件中的 `Color` 字段,首击打开。
|
||
|
||
### 11.2 Enum
|
||
|
||
1. 点击 `Enum` 首次打开下拉。
|
||
2. `Enum` 打开后切到 `Color`,首击打开颜色选择器。
|
||
3. `Color` 返回后切回 `Enum`,首击打开下拉。
|
||
|
||
### 11.3 Inline Editor
|
||
|
||
1. `Number` 点击进入编辑、提交、切换字段正常。
|
||
2. `Text` 点击进入编辑、提交、取消正常。
|
||
3. `Vector2/3/4` 组件拖拽和文本编辑不回退。
|
||
|
||
### 11.4 Focus / Popup / External Window
|
||
|
||
1. Inspector 失焦再获焦后,首击目标字段正常。
|
||
2. `ColorPicker` 关闭后,首击 `Color` 正常。
|
||
3. `Enum` popup 关闭后,首击任意 action-control 正常。
|
||
|
||
## 12. Risks and Mitigations
|
||
|
||
### Risk A. 影响面大
|
||
|
||
原因:
|
||
|
||
1. `PropertyGrid` 是多个字段类型的共同入口。
|
||
|
||
应对:
|
||
|
||
1. 按 `Action Control` 和 `Inline Editor` 分阶段切换。
|
||
2. 每阶段都以行为矩阵回归验证。
|
||
|
||
### Risk B. 向后兼容旧视觉状态
|
||
|
||
原因:
|
||
|
||
1. `UIEditorPropertyGrid.cpp` 目前从共享状态推导很多视觉态。
|
||
|
||
应对:
|
||
|
||
1. 先把 visual state 和 route state 分离。
|
||
2. 保证绘制代码只消费稳定 owner 状态,不直接猜测交互过程。
|
||
|
||
### Risk C. Vector/Drag 回归
|
||
|
||
原因:
|
||
|
||
1. `Vector` 拖拽目前和共享 session 深度耦合。
|
||
|
||
应对:
|
||
|
||
1. 把拖拽先当作 `captured owner` 行为处理。
|
||
2. 不在 action-control 重构阶段动 vector 细节。
|
||
|
||
## 13. Completion Criteria
|
||
|
||
只有同时满足下面几条,这次重构才算真正完成:
|
||
|
||
1. `Color` 首击打开问题在所有已知场景下消失。
|
||
2. `Enum`、`Bool`、`Asset`、`Color` 共享统一 action-control 输入模型。
|
||
3. `Number/Text/Vector` 不再依赖 `hadFocus` / `focused` 判定当前事件归属。
|
||
4. `pressedFieldId` 这类共享按下态不再被多个字段处理器在一次事件里交叉清理。
|
||
5. `PropertyGrid` 的输入所有权模型能够被清晰描述为:
|
||
- 路由层决定 owner
|
||
- 字段层只处理 owner 事件
|
||
- 路由层统一提交/取消/关闭 popup/切换 owner
|
||
6. 修复后不需要保留任何针对 `Color` 首击失效的临时补丁。
|
||
|
||
## 14. Recommended Implementation Order
|
||
|
||
实际开工时建议严格按下面顺序推进:
|
||
|
||
1. 改 `UIEditorPropertyGrid.h`
|
||
2. 改 `UIEditorPropertyGridInteraction.h`
|
||
3. 在 `UIEditorPropertyGridInteraction.cpp` 建立统一路由入口
|
||
4. 先重构 `Color + Enum + Bool + Asset`
|
||
5. 再重构 `Number + Text + Vector`
|
||
6. 最后清理旧模板分发和无用状态
|
||
|
||
如果顺序反过来,尤其是先在 `Color` 上局部修补,后面还会再次引入状态污染。
|