Files
XCEngine/docs/plan/NewEditor_PropertyGridInputRoutingRefactorPlan_2026-04-23.md

461 lines
17 KiB
Markdown
Raw Normal View History

# 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` 上局部修补,后面还会再次引入状态污染。