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

17 KiB
Raw Blame History

NewEditor PropertyGrid Input Routing Refactor Plan

Date: 2026-04-23 Status: Planned

1. Objective

彻底解决 new_editor Inspector PropertyGrid 中由共享输入状态污染导致的交互异常,重点包括但不限于:

  1. Color 字段关闭颜色选择器后首击失效、需要双击、甚至双击也无反应。
  2. 不同字段类型之间切换时,前一个字段的焦点、按下态、编辑态污染后一个字段。
  3. PropertyGrid 内部同样表现为“看起来都是按钮/控件”,但行为不一致,EnumColorBoolAssetNumber/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 的共享处理器内部不是只处理“当前命中的那个字段”,而是遍历所有可见字段,并把 hadFocusfocused 也视为本次事件的有效证据。
  3. 共享编辑会话 editableFieldSession 会被拷入每个字段的局部交互状态,再被某个字段处理器写回全局,导致一次事件期间多个字段都可能改写共享状态。
  4. pressedFieldIdPropertyGrid 级别的共享按下态,但多个字段处理器会在处理中途清掉它。
  5. Color 字段打开颜色选择器依赖“两阶段命中”:
    • PointerButtonDownpressedFieldId = 当前 color 字段
    • PointerButtonUp 时再次确认 pressedFieldId 仍然是该字段 所以它对共享按下态污染最敏感。
  6. Enum 下拉菜单则不同,它在 PropertyGrid 的通用 PointerButtonUp 路径里直接根据当前命中结果 OpenPopup(...),不依赖 Color 那套专门的 armed/确认链路,所以表面上表现更稳定。

结论是:当前 bug 的根不是 ColorPicker,而是 PropertyGrid 自身没有明确的输入 owner导致事件广播、共享状态回写、字段局部状态和全局状态互相污染。

3. Problem Statement

当前架构同时踩中了下面几个反模式:

  1. 广播式分发:一个事件被多套字段逻辑重复消费。
  2. 共享可变状态:pressedFieldIdeditableFieldSessionpropertyGridState.focused 由多个处理器写入。
  3. 旧焦点兜底:把 hadFocusfocused 这种陈旧状态当作本次点击的直接交互证据。
  4. 路由和行为耦合:PropertyGrid 既负责决定事件给谁,又让字段处理器反过来改全局路由状态。
  5. 字段类别缺少统一抽象:ColorEnum 都像“动作型控件”,但输入语义却不统一。

如果继续在现状上加特判,只会形成新的状态分叉,后面还会在 AssetBoolVector、键盘导航、失焦恢复上继续炸。

4. Target End State

重构完成后,PropertyGrid 必须满足下面这些结构性约束:

  1. 每个输入事件在进入字段处理前,先由 PropertyGrid 路由层解析出唯一目标。
  2. 任意时刻只有一个字段拥有“按下态”。
  3. 任意时刻只有一个字段拥有“活动编辑态”。
  4. 任意时刻只有一个字段拥有“弹出层所有权”。
  5. 字段处理器不再直接清理或重置 PropertyGrid 的共享路由状态。
  6. SelectionFocusArmedActiveEditPopupOwner 是不同概念,不能再混用。
  7. ColorEnumBoolAsset 这类动作型字段遵循统一输入契约。
  8. NumberTextVector2/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 只发给 armedFieldIdcapturedFieldId,而不是再次广播给所有字段。
  4. KeyDown / TextInput 只发给 activeEditFieldIdpopupFieldId
  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 == truefocused == 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. 明确 PointerButtonDownPointerButtonUp 的路由优先级。

完成标准:

  1. 一个事件最多只进入一个字段 owner 的处理逻辑。
  2. 非 owner 字段不会因为 hadFocus 或视觉状态变化而被视为“参与了这次交互”。

Phase B. 把 Action Control 从共享广播链路中剥离

目标:

  1. ColorEnumBoolAsset 都走统一的 action-control owner 路由。
  2. 消除 ColorEnum 当前“看起来都像按钮,但输入模型完全不同”的问题。

实施内容:

  1. 新建统一的 action-control dispatch 层。
  2. Color 不再依赖“先被别的字段处理器放过、再轮到我”的顺序偶然性。
  3. Enum 的 popup 打开逻辑保留语义,但改为经由统一 owner 路由触发。
  4. BoolAsset 也并入相同的 click activation 契约。

完成标准:

  1. ColorEnum 首击都依赖同一套 armed/release 判定语义。
  2. Action Control 的 popup / picker / toggle 行为不再受其他字段旧焦点状态影响。

Phase C. 把 Inline Editor 改成 owner-exclusive 编辑模型

目标:

  1. Number/Text/Vector 只处理当前活动编辑字段。
  2. 去掉 hadFocusfocused 参与当前事件归属判定。

实施内容:

  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. ColorEnum 共用 action-control 激活契约。
  2. 再纳入 BoolAsset
  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. 不能继续把 selectedfocusedpressedediting 当成同一种状态使用。

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 ControlInline 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. EnumBoolAssetColor 共享统一 action-control 输入模型。
  3. Number/Text/Vector 不再依赖 hadFocus / focused 判定当前事件归属。
  4. pressedFieldId 这类共享按下态不再被多个字段处理器在一次事件里交叉清理。
  5. PropertyGrid 的输入所有权模型能够被清晰描述为:
    • 路由层决定 owner
    • 字段层只处理 owner 事件
    • 路由层统一提交/取消/关闭 popup/切换 owner
  6. 修复后不需要保留任何针对 Color 首击失效的临时补丁。

实际开工时建议严格按下面顺序推进:

  1. UIEditorPropertyGrid.h
  2. UIEditorPropertyGridInteraction.h
  3. UIEditorPropertyGridInteraction.cpp 建立统一路由入口
  4. 先重构 Color + Enum + Bool + Asset
  5. 再重构 Number + Text + Vector
  6. 最后清理旧模板分发和无用状态

如果顺序反过来,尤其是先在 Color 上局部修补,后面还会再次引入状态污染。