From d7b099391ea5ee50ab5acfe6c9cc3581ecb9059a Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Thu, 23 Apr 2026 14:13:30 +0800 Subject: [PATCH] docs: add property grid input routing refactor plan --- ...GridInputRoutingRefactorPlan_2026-04-23.md | 460 ++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 docs/plan/NewEditor_PropertyGridInputRoutingRefactorPlan_2026-04-23.md diff --git a/docs/plan/NewEditor_PropertyGridInputRoutingRefactorPlan_2026-04-23.md b/docs/plan/NewEditor_PropertyGridInputRoutingRefactorPlan_2026-04-23.md new file mode 100644 index 00000000..b46bd704 --- /dev/null +++ b/docs/plan/NewEditor_PropertyGridInputRoutingRefactorPlan_2026-04-23.md @@ -0,0 +1,460 @@ +# 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` 上局部修补,后面还会再次引入状态污染。