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