diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 2815fe1b..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,421 +0,0 @@ -# AGENTS.md - -## 目的 - -这份文档服务于当前 `editor` 重构整理工作,目标不是描述理想中的未来编辑器,而是给后续 agent 一个基于当前 checkout 的真实工作底稿。 - -本文件只依据当前代码、当前目录结构、现存测试与保留的旧实现残留整理,不通过 `git` 回看旧版 `AGENTS.md` 或历史提交。 - -## 本轮结论 - -当前新 editor 的骨架已经成立,而且最关键的一刀已经落下: - -- `editor/src` 作为 `XCUI` editor-shell 基础设施层的边界是成立的。 -- `editor/app` 作为产品装配层的边界也是成立的。 -- 新 editor 已经切断对 `mvs/editor/src` 的编译期硬依赖。 - - `editor/CMakeLists.txt` 不再把 `${CMAKE_SOURCE_DIR}/mvs/editor/src` 注入 `XCEditorCore` 私有 include。 - - script assembly build 已迁入 `editor/app/Services/Runtime/EditorScriptAssemblyBuilder.*`。 - - project graphics settings 读写已迁入 `editor/app/Services/Project/ProjectGraphicsSettings.h`。 -- 这不代表迁移完全结束,但意味着后续工作已经可以继续在新链路内部收口,而不是一边重构一边继续借旧 editor 活着。 - -## 下阶段目标 - -### 1. 先守边界,再做整理 - -- 把 `editor/src` 视为 `XCUI` editor-shell 基础设施层,只放通用 editor UI primitive、workspace、dock、panel host、viewport shell、window sync primitive。 -- 把 `editor/app` 视为产品装配层,只放产品清单、业务面板、runtime owner、engine bridge、Win32/D3D12 host、窗口生命周期。 -- 不再把业务能力反向塞回 `editor/src`,也不要把 `editor/app` 里的产品判断下沉到 `XCUIEditor`。 - -### 2. 不要重新引入旧 editor 的编译期回流 - -- `mvs/editor/src` 仍然保留在仓库里,但它已经不是新 editor 的私有底座。 -- 如果新 editor 还需要旧链路中的某个工具能力,先迁入 `editor/app` 自己的命名空间和目录,再使用。 -- 不要重新把 `mvs/editor/src` 加回 `XCEditorCore` 的 include path,也不要再把旧 editor 的 `.cpp` 直接编进新 editor。 - -### 3. 收口命令 ownership - -- 现在命令壳已经完整,但 owner 仍不完整。 -- 下一阶段要明确三类 owner: - - 文档 owner:`file.new_project / file.open_project / file.save_project / file.open_scene / file.save_scene_as` - - 资产 owner:`assets.reimport_selected / assets.reimport_all / assets.clear_library` - - 剪贴板 owner:`edit.cut / edit.copy / edit.paste` -- 不要继续容忍“命令已注册但没有真实 owner”的状态扩散。 - -### 4. 缩小组合壳,不再制造新的大总管 - -- `EngineEditorComposition` 目前已经把 consumer 依赖表面收窄成 `EditorSceneBackendFactory`、`SceneViewportEngineBridge`、`GameViewportEngineBridge`、`EditorShaderProvider`、`EditorEngineLifecycle`。 -- 但内部仍是一个 `EngineEditorServices` 兼容壳实现多接口。 -- 下一阶段原则是继续缩小依赖面,而不是再拆文件名。 - -### 5. 把多窗口链路当成主线能力维护 - -- 当前 editor 已经不是单窗口壳。 -- `EditorWindowSystem`、`EditorWindowWorkspaceCoordinator`、`EditorWindowSynchronizationPlanner`、全局 tab 拖拽和 detached window projection 是真实主线,不是附属实验。 -- 重构时必须保住 authoritative workspace state + host window synchronization 这条链。 - -### 6. 让验证入口继续跟着新 editor 走 - -- `tests/UI/Editor/unit` 已经是当前新 editor 的正式验证面。 -- 下一阶段应继续围绕 command owner、window sync、scene document、project workflow 补测试,而不是回退到人工验证或旧 `mvs/editor` 行为比对。 - -## 当前事实 - -### 1. 当前 editor 不是一个库,而是五层构建产物 - -- `XCUIEditor` - - 纯 editor UI 基础层。 - - 来源主要是 `editor/src/**` 和 `editor/include/XCEditor/**`。 - - 直接链接 `XCEngineUI`。 -- `XCEditorCore` - - 新 editor 的产品逻辑、panel runtime、context、runtime coordinator、windowing orchestration。 - - 主要来源是 `editor/app/Core`、`editor/app/Composition`、`editor/app/Features`、`editor/app/Services`、`editor/app/Windowing`。 -- `XCEditorRendering` - - viewport runtime、pass bundle、icon service、render service。 -- `XCEditorHost` - - Win32 + D3D12 宿主层。 -- `XCEditor` - - 最终可执行壳,输出名是 `XCEngine`。 - -结论: - -- 当前重构不能把 `editor` 当成“一个目录里的单块程序”处理。 -- 真正的边界首先是 target 边界,而不是文件夹美学。 - -### 2. 当前 editor 的入口已经完全在新链路 - -- 入口是 `editor/app/main.cpp -> Application::Run()` -- `Application` 负责: - - 解析 runtime path - - 初始化 engine composition - - 初始化 `EditorContext` - - 初始化 `EditorWindowSystem` - - 初始化 `EditorWindowManager` - - 创建主 workspace window - - 驱动消息循环、tick runtime、render all windows - -`Application` 当前已经不是旧 editor 的 `Layer + ImGui` 壳,而是新的宿主装配器。 - -### 3. 当前产品单一事实源是 `EditorProductManifest` - -- panel 列表由 `editor/app/Core/Product/EditorProductManifest.*` 定义。 -- 当前正式 panel 共 6 个: - - `hierarchy` - - `scene` - - `game` - - `inspector` - - `console` - - `project` -- panel 的以下事实都来自这里: - - `panelId` - - 默认标题 - - hosted content 还是 viewport shell - - runtime owner kind - - viewport renderer kind - - action route - -结论: - -- 新增 panel、改 panel 类型、改 viewport owner,不应绕开 manifest。 -- `EditorWorkspacePanelRegistry` 和 `ViewportHostService` 都是从 manifest 派生,不是各自独立真相源。 - -### 4. `editor/src` 已经形成完整的 shell 基础设施层 - -当前 `editor/src` 明确包含这些子域: - -- `Foundation` -- `Fields` -- `Collections` -- `Docking` -- `Menu` -- `Panels` -- `Shell` -- `Viewport` -- `Workspace` -- `Windowing` -- `Widgets` - -这层已经提供: - -- command dispatcher / registry / shortcut manager -- dock host / tab strip / tree view / list view / scroll view / property grid -- panel content host / hosted panel dispatch / panel lifecycle -- workspace model / session / controller / layout persistence / compose / transfer -- viewport shell / input bridge / slot -- window synchronization planner / window workspace store / presentation policy - -结论: - -- `editor/src` 已经不是“几个组件”的状态,而是一整层 editor-shell framework。 -- 这层的重构应该按 primitive 和 state machine 做,不应该混入业务 panel 需求。 - -### 5. `EditorShellAsset` 是新 shell 的装配快照 - -`EditorContext::Initialize()` 会构建 `EditorShellAsset`,其中包含: - -- panel registry -- workspace model -- workspace session -- shell definition -- command registry -- shortcut bindings - -默认工作区布局当前是: - -- 左侧 `Hierarchy` -- 中间 tab stack:`Scene` / `Game` -- 右侧 `Inspector` -- 底部 tab stack:`Console` / `Project` - -### 6. `EditorContext` 是当前产品态的中心状态容器 - -`EditorContext` 当前直接持有: - -- `EditorSession` -- `EditorSelectionService` -- `EditorProjectRuntime` -- `EditorSceneRuntime` -- `EditorRuntimeCoordinator` -- `EditorColorPickerToolState` -- `EditorUtilityWindowRequestState` -- session/status console projection - -它同时承担: - -- shell definition 生成 -- 状态栏文本更新 -- panel frame event 向 session console 的投影 -- workspace controller 初始构造 - -结论: - -- `EditorContext` 现在是产品状态边界,不是旧 editor 的 service locator 复制品。 -- 它仍偏重,后续若继续拆,优先拆 projection / status / console,而不是打散 scene/project/runtime owner。 - -### 7. 运行时链路已经闭合到可工作的程度 - -当前真实链路是: - -`Application` --> `EditorContext` --> `EditorWindowManager` --> `EditorWorkspaceWindowContentController` --> `EditorShellRuntime` --> `EditorWorkspacePanelRuntimeSet` --> `Project / Scene / Inspector / Hierarchy / Game / Console` --> `ViewportHostService` --> `SceneViewportRenderService / GameViewportRenderService` --> engine bridge - -这里有几个关键事实: - -- `Game` 面板当前不是 placeholder。 -- `ViewportHostService` 会根据 `EditorProductManifest` 把 viewport panel 绑定到 scene/game renderer。 -- 当前产品清单没有把 `scene` 或 `game` 配成 placeholder。 - -### 8. 当前 editor 已支持多窗口 authoritative state - -`EditorWindowSystem` 维护 authoritative `UIEditorWindowWorkspaceSet`。 - -`EditorWindowWorkspaceCoordinator` 负责: - -- 从 authoritative state 推导 host window sync plan -- detached window create / close / update -- global tab drag -- panel detach to new window -- drop preview 与 title/projection 更新 - -`EditorWorkspaceWindowContentController` 每帧都会从 `EditorWindowSystem` 取 live workspace controller,而不是把窗口内状态当作真相源。 - -结论: - -- 当前多窗口不是局部 patch,而是架构前提。 -- 重构时不要把 per-window runtime 私有状态重新升格为 authoritative state。 - -### 9. Scene runtime 已经不是薄壳 - -`EditorSceneRuntime` 当前已经承担: - -- active scene 初始化 / 打开 / 保存 / 新建 -- selection 同步 -- hierarchy snapshot -- selected object snapshot / component descriptor -- component 增删改 -- transform 修改 -- duplicate / delete / reparent / move -- scene edit transaction -- undo / redo -- play session 派生 - -结论: - -- scene 编辑的真正 owner 是 `EditorSceneRuntime`,不是 hierarchy panel,也不是 inspector panel。 -- 任何 scene 编辑重构都应以它为 mutation boundary。 - -### 10. Runtime coordinator 已经具备文档和 play-mode owner 雏形 - -`EditorRuntimeCoordinator` 当前已经真正拥有: - -- `file.new_scene` -- `file.save_scene` -- `run.play` -- `run.pause` -- `run.step` -- `run.stop` -- `scripts.rebuild` -- scene dirty projection -- `Edit / Play / Paused` 模式切换 - -但以下命令仍明显不完整: - -- `file.new_project` -- `file.open_project` -- `file.save_project` -- `file.open_scene` - - 当前评价结果仍依赖“当前 shell 里已有 project scene selection” -- `file.save_scene_as` - - 当前明确因缺少 file dialog host 而不可用 - -### 11. Project runtime 与 Project panel 已经可用,但仍未收口 - -当前已实现的项目侧能力包括: - -- folder navigation -- asset selection -- scene asset open request -- create folder -- create material -- rename -- delete -- move / reparent -- show in explorer -- copy project path - -当前明确未收口或未拥有的能力包括: - -- `edit.duplicate` -- `edit.cut / edit.copy / edit.paste` -- `assets.reimport_selected` -- `assets.reimport_all` -- `assets.clear_library` - -### 12. Inspector 已经是多 subject 面板,不只是组件列表 - -当前 Inspector 明确覆盖: - -- scene object subject -- project selection subject -- 空选择态 -- fallback unsupported presentation - -并且已经接入: - -- component editor registry -- `ScriptComponentInspectorComponentEditor` -- `TransformInspectorComponentEditor` -- collider / renderer / camera / light / rigidbody 等组件 editor -- `Add Component` utility window -- color picker utility window - -### 13. Console 现在更像 session/status console,不是完整诊断前端 - -当前 `Console` 主要消费的是 `EditorContext` 投影出来的 `session.consoleEntries`。 - -它目前承接的是: - -- command dispatch message -- layout / workspace message -- panel frame event -- status message - -但它还不是一个完整的 runtime/script diagnostic hub。 - -### 14. 新 editor 已切断对旧 `mvs/editor` 的编译期硬依赖 - -这是当前 checkout 最重要的事实之一。 - -新 editor app 现在已经不再: - -- 在 `XCEditorCore` 私有 include 中注入 `${CMAKE_SOURCE_DIR}/mvs/editor/src` -- 直接把 `mvs/editor/src/Scripting/EditorScriptAssemblyBuilder.cpp` 编进新 editor -- 在 `EditorScriptingRuntimeService.cpp` 中直接 include 旧 editor 的 - - `Platform/Win32Utf8.h` - - `Utils/ProjectGraphicsSettings.h` - - `Scripting/EditorScriptAssemblyBuilder.h` - -当前替代落点是: - -- `editor/app/Services/Runtime/EditorScriptAssemblyBuilder.*` -- `editor/app/Services/Project/ProjectGraphicsSettings.h` -- `editor/app/Support/StringEncoding.h` - -结论: - -- “新 editor 已完全完成迁移”这件事仍不能说死,因为 command owner、document host、asset workflow 还没收口。 -- 但“新 editor 仍编译期依赖旧 editor 私有目录”这个判断已经过期。 - -### 15. `EngineEditorComposition` 已经收窄对外依赖,但内部兼容壳还在 - -对 consumer 来说,现在看到的是窄接口: - -- `EditorSceneBackendFactory` -- `SceneViewportEngineBridge` -- `GameViewportEngineBridge` -- `EditorShaderProvider` -- `EditorEngineLifecycle` - -但内部 `Impl` 仍是一个 `EngineEditorServices` 同时实现这些接口。 - -### 16. 测试基础已经相当扎实 - -当前 `tests/UI/Editor/unit` 已是新 editor 的正式验证面,构建产物至少包括: - -- `editor_app_core_tests.exe` -- `editor_app_feature_tests.exe` -- `editor_ui_tests.exe` -- `editor_windowing_phase1_tests.exe` - -本轮切断旧 editor 编译期依赖后,`EditorRuntimeCoordinatorTests` 已在新产物上通过。 - -## 过去情况 - -### 1. 仓库里保留着一套明确的旧 editor - -旧链路主要位于 `mvs/editor/src`,目录结构仍然清晰可见,例如: - -- `Actions` -- `Commands` -- `Core` -- `Layers` -- `Layout` -- `Managers` -- `panels` -- `Viewport` -- `UI` -- `XCUIBackend` - -### 2. 旧 editor 是 ImGui 主导、单窗口主导的结构 - -从现存代码可以直接确认: - -- 旧 `Application` 负责 ImGui 初始化 -- 旧 `EditorLayer` 通过 `LayerStack` 驱动 editor -- 旧 `DockLayoutController` 使用 `ImGui::DockBuilder*` 硬编码默认布局 -- 旧 `ViewportHostService`、`Panel`、`ActionRouter` 大量直接依赖 ImGui - -### 3. 新 editor 不是“旧 editor 改皮”,而是换了骨架 - -新 editor 已经明确完成的骨架替换包括: - -- Dear ImGui 面板体系 -> `XCUIEditor` shell primitive -- 旧 dockspace -> `UIEditorWorkspaceModel` + `DockHost` + `WindowSystem` -- 单窗口默认假设 -> 多窗口 authoritative state + synchronization plan -- 面板直接 owning 运行逻辑 -> panel runtime + runtime owner + bridge -- 旧 host UI backend -> Win32 + D3D12 新 host runtime - -## 工作约束 - -- 做新 editor 相关重构时,优先读取 `editor/CMakeLists.txt`、`EditorProductManifest`、`EditorContext`、`EditorWindowManager`、`EditorWorkspacePanelRegistry`,不要先从旧 `mvs/editor` 推演。 -- 除非任务明确是抽取遗留依赖,否则不要在 `mvs/editor/src` 上继续扩功能。 -- 新增 panel、命令、viewport owner、window projection 规则时,先改新链路单一事实源,再改派生层。 -- 若改到 command owner、workspace sync、panel runtime、scene mutation,默认补对应 `tests/UI/Editor/unit`。 - diff --git a/editor/AGENTS.md b/editor/AGENTS.md deleted file mode 100644 index 8853e20e..00000000 --- a/editor/AGENTS.md +++ /dev/null @@ -1,211 +0,0 @@ -# Editor Agent Guide - -This file is for agents working in `editor/**` and `tests/UI/Editor/**`. -Treat the current code and tests as source of truth. If this guide drifts from -the checkout, update it in the same change. - -## Current Priority - -The editor is not in a "split more layers for their own sake" phase. - -The primary product line is still runtime/product loop closure: - -- extend the bound `EditorRuntimeCoordinator` instead of adding panel-local - shortcuts for `run.*`, scene document commands, or project scene opens -- keep play mode transactional: `EditorRuntimeCoordinator` must enter play - through `EditorSceneRuntime::BeginPlaySession`, and `RuntimeLoop` must run - the play-session runtime scene rather than the editable document scene -- drive `EditorRuntimeCoordinator` from the app frame pump exactly once per - outer frame; do not tick runtime from per-window shell/content update paths -- keep `scripts.*` coordinator-owned and capability-driven. `scripts.rebuild` - must evaluate/dispatch through the bound scripting runtime service and expose - the real availability/failure message rather than a hardcoded stub -- keep `Game`, `Scene`, `Inspector`, `Selection`, and `Console` coherent across - runtime transitions - -Do not burn time on broad architectural churn unless it directly unlocks that -product loop or removes an active architectural hazard. - -## Engine Boundary Status - -The old public `EditorEngineServices` god interface is gone. - -The current editor-to-engine boundaries are narrow and must stay narrow: - -- `EditorSceneBackendFactory` -- `SceneViewportEngineBridge` -- `GameViewportEngineBridge` -- `EditorShaderProvider` -- `EditorEngineLifecycle` - -`EngineEditorComposition` in `editor/app/Services/Engine/EngineEditorServices.*` -is an internal composition root that hands out those narrow interfaces. It is -not a new shared facade for features to depend on. - -Rules: - -- do not recreate `EditorEngineServices` -- do not add a replacement god interface under a different name -- do not make new panels, passes, or runtimes depend on a catch-all engine - service locator -- keep render-pass dependencies low-level: passes may depend on - `EditorShaderProvider`, not on scene backend creation or lifecycle code -- composition owns assembly; features and passes must receive explicit - dependencies - -## Product Truths - -- `GameViewportFeature`, `GameViewportRenderService`, and related tests exist. - `EditorRuntimeCoordinator` is now the app-level owner for play-mode command - routing and uses `RuntimeLoop` for the active play-session scene. -- Play mode is a scene transaction. The engine scene backend snapshots the - editable scene, replaces it with a runtime scene for play/step, and restores - the editable scene when the play session ends. Do not start runtime playback - directly from `EditorSceneRuntime::GetActiveScene()`. -- `EditorHostCommandBridge` delegates `file.*`, `run.*`, and `scripts.*` to a - bound runtime owner. If no owner is bound, it must continue exposing honest - disabled messages. -- host-command context is workspace-shell local, not editor-global. The active - panel fallback and command-focus resolution must come from the current - `UIEditorWorkspaceController` plus that shell's - `EditorCommandFocusService`. Do not put window-local command routing back on - `EditorSession`, `EditorContext`, or any app-global bridge/shortcut manager. -- `EditorSceneRuntime` owns raw scene editing behavior and scene mutations. It - may return transient startup/open/new results, but it must not own the live - scene document state. Scene viewport private state now lives in - `SceneViewportSession`, owned by the scene viewport feature/panel instance, - not by `EditorContext`, `EditorFrameServices`, or the shared scene runtime. - `EditorSceneRuntime` also owns the unified scene-edit transaction/history - layer: hierarchy edits, inspector mutations, and scene gizmo commits must all - flow through the same backend snapshot-based undo/redo path rather than - panel-local or transform-only history. `EditorRuntimeCoordinator` owns scene - document state: current path/name, dirty flag, new/open/save routing, and - runtime-mode transitions. -- `EditorRuntimeCoordinator` time advancement is app-owned. It must tick once - per outer application frame before window rendering, not once per workspace - window or once per shell update. -- `ProjectPanel` may identify an openable scene asset, but scene document loading - must go through the bound typed scene-open request callback and the - coordinator. Do not reintroduce `ProjectRuntime` pending-open queues. -- `scripts.rebuild` is coordinator-owned and routes through the bound - scripting-runtime service. Keep its evaluation/dispatch honest: expose the - live capability/failure message from that service rather than a hardcoded - placeholder. -- `EditorSession` may still exist as an app/coordinator projection, but it is - not the live source of truth for panel-local runtime state. Live selection - belongs to the bound selection/project/scene runtimes, and live console data - belongs to the owned console buffer. Do not make `Inspector` or `Console` - read their current state back from `EditorSession`. -- the old shared `EditorPanelServices` dependency bag is gone. Workspace and - utility panels now receive explicit bindings or panel-local contexts; do not - recreate a catch-all mutable panel service bundle under a new name -- `EditorFrameServices` remains a window/content orchestration boundary. It must - stay on the window/content and shell-orchestration seam only. It must not be - passed through the workspace-panel seam or the utility-window panel seam as a - generic dependency source; workspace panels and utility panels should receive - explicit panel-local bindings or typed callbacks instead. Runtime time-step - ownership does not belong on this interface. Command routing ownership also - does not belong on this interface: do not add host-command bridge bindings, - command-focus syncing, or active-route/session transport back to - `EditorFrameServices`. - -## Working Rules - -- prefer explicit owner/coordinator/runtime-service seams over direct panel to - engine hookups -- keep panel dependencies explicit. If a panel needs more data or actions, add a - narrow panel-local context or binding instead of routing everything through a - generic shared services struct -- keep utility-window dependencies explicit. Color picker, add-component, and - future utility panels must be constructed with the exact runtime/tool state - they use; do not make utility panel update paths depend on - `EditorFrameServices` -- keep command routing shell-local. `UIEditorShortcutManager`, - `EditorHostCommandBridge`, and `EditorCommandFocusService` must be owned by - the workspace shell/runtime set that serves one window. If a command needs - active-panel context, pass the current `UIEditorWorkspaceController`; do not - mirror that context into `EditorSession` -- keep viewport-local state local. Camera/tool mode/pivot/space state for Scene - belongs in `SceneViewportSession` on the feature/controller side; do not push - it back into shared runtime/context/frame-service layers for convenience -- when a panel needs live editor state, bind the owning runtime/service/buffer - directly. Do not route live inspector selection or console entries through - `EditorSession` for convenience -- do not reintroduce `void*` plus callback-function panel hooks for scene-open - or utility-window requests; use typed callbacks or explicit requester - interfaces instead -- when changing runtime or document flow, inspect `EditorSession`, - `EditorRuntimeCoordinator`, `EditorHostCommandBridge`, the relevant feature, - and the matching tests -- if a change affects ownership, state flow, or command routing, add or update - tests in `tests/UI/Editor/unit` -- keep multi-window work behind runtime/document correctness; do not move it - ahead of core ownership closure - -## Do Not Regress - -- no new service-locator style editor-to-engine dependency -- no new low-level render pass dependency on scene backend creation or lifecycle -- no silent fallback where a command should fail honestly -- no panel-local shortcut that bypasses the intended runtime owner -- no new shared mutable panel-services bag or service-locator style panel - dependency bundle -- no `EditorFrameServices` tunneling into workspace-panel update/prepare paths -- no `EditorFrameServices` tunneling into utility-window panel update paths -- no global host-command bridge or global shortcut manager shared across editor - windows -- no `EditorSession.activePanelId`, `EditorSession.activeRoute`, or equivalent - session mirrors for per-window command context -- no new panel path that treats `EditorSession.selection` or - `EditorSession.consoleEntries` as live UI state for `Inspector`, `Console`, - or other runtime-bound panels -- no per-window or per-shell runtime ticking path; runtime must not advance once - per workspace window -- no new live scene document metadata on `EditorSceneRuntime`; coordinator - remains the single owner for current path/name/dirty state -- no new path where Scene viewport render requests or camera/tool state are - recomputed from `EditorContext` instead of the owning `SceneViewportSession` -- no scene-open side channel in `EditorProjectRuntime`; project UI must call the - panel service request hook -- no play-mode path that mutates the editable scene in place or skips - `EditorScenePlaySession` -- no new transform-only or panel-local scene undo stack; all scene mutations - must record through `EditorSceneRuntime`'s unified snapshot transaction - history - -## Good Entry Points - -- `editor/app/Bootstrap/Application.cpp` -- `editor/app/Composition/EditorContext.cpp` -- `editor/app/Composition/EditorShellRuntime.cpp` -- `editor/app/Services/Runtime/EditorRuntimeCoordinator.cpp` -- `editor/app/Rendering/Viewport/ViewportHostService.cpp` -- `editor/app/Services/Engine/EngineEditorServices.h` -- `editor/app/Services/Scene/EditorSceneRuntime.cpp` -- `editor/app/Core/Commands/EditorHostCommandBridge.cpp` -- `docs/plan/editor-next-stage-runtime-plan.md` -- `docs/plan/editor-engine-services-refactor-plan.md` - -## Verification - -Minimum verification for editor app/core changes: - -```powershell -cmake --build build --config Debug --target editor_app_core_tests -cmake --build build --config Debug --target editor_app_feature_tests -``` - -When the executable path is affected, also rebuild: - -```powershell -cmake --build build --config Debug --target XCEditor -``` - -Relevant focused tests include: - -- `test_editor_host_command_bridge.cpp` -- `test_editor_runtime_coordinator.cpp` -- `test_game_viewport_runtime.cpp` -- `test_project_panel.cpp` -- `test_scene_viewport_render_plan.cpp` -- `test_scene_viewport_runtime.cpp` diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 06fb403e..788503bc 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -95,6 +95,7 @@ set(XCUI_EDITOR_MENU_SOURCES src/Menu/UIEditorMenuBar.cpp src/Menu/UIEditorMenuModel.cpp src/Menu/UIEditorMenuPopup.cpp + src/Menu/UIEditorMenuPopupInteraction.cpp src/Menu/UIEditorMenuSession.cpp ) diff --git a/editor/app/Features/Inspector/InspectorPanel.cpp b/editor/app/Features/Inspector/InspectorPanel.cpp index 144a8e37..80119c0c 100644 --- a/editor/app/Features/Inspector/InspectorPanel.cpp +++ b/editor/app/Features/Inspector/InspectorPanel.cpp @@ -287,6 +287,7 @@ void InspectorPanel::ResetPanelState() { m_subjectKey.clear(); m_presentation = {}; m_gridFrame = {}; + m_popupOverlay = {}; m_knownSectionIds.clear(); m_lastSceneSelectionStamp = 0u; m_lastProjectSelectionStamp = 0u; @@ -303,6 +304,8 @@ void InspectorPanel::ResetInteractionState() { m_scrollVerticalOffset = 0.0f; m_interactionState = {}; m_gridFrame = {}; + m_popupOverlay = {}; + ResetUIEditorMenuPopupInteractionState(m_popupInteractionState); m_lastAppliedColorPickerRevision = 0u; ResetAddComponentButtonState(); } @@ -855,14 +858,12 @@ void InspectorPanel::Update( RefreshPresentation(context, subjectChanged); ApplyColorPickerToolValue(context); RebuildScrollableLayout(); - - Widgets::UIEditorMenuPopupLayout popupLayout = {}; - Widgets::UIEditorMenuPopupState popupState = {}; - std::vector popupItems = {}; - const bool popupOpen = - BuildPropertyGridPopupRuntime(popupLayout, popupState, popupItems); + RebuildPropertyGridPopupOverlay(); + const bool popupOpen = m_popupOverlay.open; const std::vector popupBounds = - popupOpen ? std::vector{ popupLayout.popupRect } : std::vector{}; + popupOpen + ? std::vector{ m_popupOverlay.layout.popupRect } + : std::vector{}; const std::vector filteredEvents = BuildUIEditorPanelInputEvents( @@ -887,7 +888,9 @@ void InspectorPanel::Update( popupBounds.empty() ? nullptr : &popupBounds); const std::vector contentEvents = popupOpen - ? BuildContentInputEventsExcludingPopup(filteredEvents, popupLayout.popupRect) + ? BuildContentInputEventsExcludingPopup( + filteredEvents, + m_popupOverlay.layout.popupRect) : filteredEvents; TryClaimHostedPanelCommandFocus( m_commandFocusService, @@ -954,13 +957,34 @@ void InspectorPanel::Update( } } - if (popupOpen) { - HandlePropertyGridPopupOverlayInteraction( - context, - BuildPopupInputEvents(filteredEvents, popupLayout.popupRect)); + RebuildScrollableLayout(); + RebuildPropertyGridPopupOverlay(); + + if (m_popupOverlay.open) { + const UIEditorMenuPopupInteractionFrame popupFrame = + UpdateUIEditorMenuPopupInteraction( + m_popupInteractionState, + UIEditorMenuPopupInteractionRequest{ + .layout = m_popupOverlay.layout, + .items = m_popupOverlay.items, + .preferredHighlightedItemId = {}, + .closeOnFocusLost = false, + }, + BuildPopupInputEvents( + filteredEvents, + m_popupOverlay.layout.popupRect)); + m_popupOverlay.widgetState = popupFrame.popupState; + m_interactionState.propertyGridState.popupHighlightedIndex = + popupFrame.popupState.hoveredIndex; + if (popupFrame.result.itemActivated) { + ApplyPropertyGridPopupActivation( + context, + popupFrame.result.activatedIndex); + RebuildScrollableLayout(); + RebuildPropertyGridPopupOverlay(); + } } - RebuildScrollableLayout(); UpdateAddComponentButton(context, contentEvents); } @@ -1068,14 +1092,7 @@ void InspectorPanel::Append(UIDrawList& drawList) const { } void InspectorPanel::AppendOverlay(UIDrawList& drawList) const { - if (!m_visible) { - return; - } - - Widgets::UIEditorMenuPopupLayout popupLayout = {}; - Widgets::UIEditorMenuPopupState popupState = {}; - std::vector popupItems = {}; - if (!BuildPropertyGridPopupRuntime(popupLayout, popupState, popupItems)) { + if (!m_visible || !m_popupOverlay.open) { return; } @@ -1085,152 +1102,102 @@ void InspectorPanel::AppendOverlay(UIDrawList& drawList) const { ResolveUIEditorMenuPopupPalette(); Widgets::AppendUIEditorMenuPopupBackground( drawList, - popupLayout, - popupItems, - popupState, + m_popupOverlay.layout, + m_popupOverlay.items, + m_popupOverlay.widgetState, popupPalette, popupMetrics); Widgets::AppendUIEditorMenuPopupForeground( drawList, - popupLayout, - popupItems, - popupState, + m_popupOverlay.layout, + m_popupOverlay.items, + m_popupOverlay.widgetState, popupPalette, popupMetrics); } std::vector InspectorPanel::CollectInteractiveOverlayBounds() const { - Widgets::UIEditorMenuPopupLayout popupLayout = {}; - Widgets::UIEditorMenuPopupState popupState = {}; - std::vector popupItems = {}; - if (!BuildPropertyGridPopupRuntime(popupLayout, popupState, popupItems) || - popupLayout.popupRect.width <= 0.0f || - popupLayout.popupRect.height <= 0.0f) { + if (!m_popupOverlay.open || + m_popupOverlay.layout.popupRect.width <= 0.0f || + m_popupOverlay.layout.popupRect.height <= 0.0f) { return {}; } - return { popupLayout.popupRect }; + return { m_popupOverlay.layout.popupRect }; } -bool InspectorPanel::BuildPropertyGridPopupRuntime( - Widgets::UIEditorMenuPopupLayout& popupLayout, - Widgets::UIEditorMenuPopupState& popupState, - std::vector& popupItems) const { +void InspectorPanel::RebuildPropertyGridPopupOverlay() { + m_popupOverlay = {}; if (!m_visible || m_gridFrame.layout.bounds.width <= 0.0f || m_gridFrame.layout.bounds.height <= 0.0f) { - return false; + ResetUIEditorMenuPopupInteractionState(m_popupInteractionState); + return; } - return Widgets::BuildUIEditorPropertyGridPopupRuntime( + Widgets::UIEditorMenuPopupLayout popupLayout = {}; + Widgets::UIEditorMenuPopupState popupState = {}; + std::vector popupItems = {}; + if (!Widgets::BuildUIEditorPropertyGridPopupRuntime( m_gridFrame.layout, m_presentation.sections, m_interactionState.propertyGridState, popupLayout, popupState, popupItems, - ResolveUIEditorMenuPopupMetrics()); + ResolveUIEditorMenuPopupMetrics())) { + ResetUIEditorMenuPopupInteractionState(m_popupInteractionState); + return; + } + + m_popupInteractionState.focused = popupState.focused; + const UIEditorMenuPopupInteractionFrame popupFrame = + UpdateUIEditorMenuPopupInteraction( + m_popupInteractionState, + UIEditorMenuPopupInteractionRequest{ + .layout = popupLayout, + .items = popupItems, + .preferredHighlightedItemId = {}, + .closeOnFocusLost = false, + }, + {}); + m_popupOverlay.open = true; + m_popupOverlay.layout = popupFrame.layout; + m_popupOverlay.widgetState = popupFrame.popupState; + m_popupOverlay.items = std::move(popupItems); + m_interactionState.propertyGridState.popupHighlightedIndex = + popupFrame.popupState.hoveredIndex; } -bool InspectorPanel::HandlePropertyGridPopupOverlayInteraction( +bool InspectorPanel::ApplyPropertyGridPopupActivation( InspectorPanelContext& context, - const std::vector& inputEvents) { - if (inputEvents.empty()) { + std::size_t activatedIndex) { + const std::string popupFieldId = m_interactionState.propertyGridState.popupFieldId; + if (popupFieldId.empty()) { return false; } - Widgets::UIEditorMenuPopupLayout popupLayout = {}; - Widgets::UIEditorMenuPopupState popupState = {}; - std::vector popupItems = {}; - if (!BuildPropertyGridPopupRuntime(popupLayout, popupState, popupItems)) { + m_interactionState.propertyGridState.popupFieldId.clear(); + m_interactionState.propertyGridState.popupHighlightedIndex = + Widgets::UIEditorPropertyGridInvalidIndex; + ResetUIEditorMenuPopupInteractionState(m_popupInteractionState); + + Widgets::UIEditorPropertyGridField* field = + FindMutableField(popupFieldId); + if (field == nullptr || + field->kind != Widgets::UIEditorPropertyGridFieldKind::Enum || + activatedIndex >= field->enumValue.options.size()) { return false; } - bool handled = false; - for (const UIInputEvent& event : inputEvents) { - const Widgets::UIEditorMenuPopupHitTarget hitTarget = - Widgets::HitTestUIEditorMenuPopup( - popupLayout, - popupItems, - event.position); - - switch (event.type) { - case UIInputEventType::PointerMove: - case UIInputEventType::PointerEnter: - m_interactionState.propertyGridState.popupHighlightedIndex = - hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && - hitTarget.index < popupItems.size() && - popupItems[hitTarget.index].enabled - ? hitTarget.index - : Widgets::UIEditorMenuPopupInvalidIndex; - handled = true; - break; - - case UIInputEventType::PointerButtonDown: - if (event.pointerButton != UIPointerButton::Left) { - break; - } - - if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && - hitTarget.index < popupItems.size() && - popupItems[hitTarget.index].enabled) { - m_interactionState.propertyGridState.focused = true; - m_interactionState.pressedPopupIndex = hitTarget.index; - handled = true; - } else if (hitTarget.kind == - Widgets::UIEditorMenuPopupHitTargetKind::PopupSurface) { - m_interactionState.propertyGridState.focused = true; - handled = true; - } - break; - - case UIInputEventType::PointerButtonUp: - if (event.pointerButton != UIPointerButton::Left) { - break; - } - - if (m_interactionState.pressedPopupIndex != - Widgets::UIEditorPropertyGridInvalidIndex && - hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && - hitTarget.index == m_interactionState.pressedPopupIndex && - hitTarget.index < popupItems.size() && - popupItems[hitTarget.index].enabled) { - const std::string popupFieldId = - m_interactionState.propertyGridState.popupFieldId; - m_interactionState.propertyGridState.popupHighlightedIndex = hitTarget.index; - m_interactionState.propertyGridState.popupFieldId.clear(); - m_interactionState.pressedPopupIndex = - Widgets::UIEditorPropertyGridInvalidIndex; - - Widgets::UIEditorPropertyGridField* field = - FindMutableField(popupFieldId); - if (field != nullptr && - field->kind == Widgets::UIEditorPropertyGridFieldKind::Enum && - hitTarget.index < field->enumValue.options.size()) { - field->enumValue.selectedIndex = hitTarget.index; - if (ApplyChangedField(field->fieldId)) { - RefreshPresentation(context, false); - } else { - ForceResyncPresentation(context); - } - } - handled = true; - break; - } - - if (hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None) { - m_interactionState.pressedPopupIndex = - Widgets::UIEditorPropertyGridInvalidIndex; - handled = true; - } - break; - - default: - break; - } + field->enumValue.selectedIndex = activatedIndex; + if (ApplyChangedField(field->fieldId)) { + RefreshPresentation(context, false); + } else { + ForceResyncPresentation(context); } - return handled; + return true; } UIEditorHostCommandEvaluationResult InspectorPanel::EvaluateEditCommand( diff --git a/editor/app/Features/Inspector/InspectorPanel.h b/editor/app/Features/Inspector/InspectorPanel.h index 0fb0dc7f..7484f17e 100644 --- a/editor/app/Features/Inspector/InspectorPanel.h +++ b/editor/app/Features/Inspector/InspectorPanel.h @@ -7,7 +7,7 @@ #include #include #include -#include +#include #include #include @@ -63,6 +63,13 @@ public: std::string_view commandId) override; private: + struct PropertyGridPopupOverlayState { + bool open = false; + Widgets::UIEditorMenuPopupLayout layout = {}; + Widgets::UIEditorMenuPopupState widgetState = {}; + std::vector items = {}; + }; + void ResetPanelState(); void ResetInteractionState(); bool HasActivePointerCapture() const; @@ -88,13 +95,10 @@ private: bool ApplyColorPickerToolValue(InspectorPanelContext& context); void RequestColorPicker(InspectorPanelContext& context, std::string_view fieldId); bool HandleActivatedField(std::string_view fieldId); - bool BuildPropertyGridPopupRuntime( - Widgets::UIEditorMenuPopupLayout& popupLayout, - Widgets::UIEditorMenuPopupState& popupState, - std::vector& popupItems) const; - bool HandlePropertyGridPopupOverlayInteraction( + void RebuildPropertyGridPopupOverlay(); + bool ApplyPropertyGridPopupActivation( InspectorPanelContext& context, - const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents); + std::size_t activatedIndex); void ResetAddComponentButtonState(); void UpdateAddComponentButton( InspectorPanelContext& context, @@ -116,6 +120,8 @@ private: float m_scrollVerticalOffset = 0.0f; UIEditorPropertyGridInteractionState m_interactionState = {}; UIEditorPropertyGridInteractionFrame m_gridFrame = {}; + PropertyGridPopupOverlayState m_popupOverlay = {}; + UIEditorMenuPopupInteractionState m_popupInteractionState = {}; std::unordered_set m_knownSectionIds = {}; std::uint64_t m_lastSceneSelectionStamp = 0u; std::uint64_t m_lastProjectSelectionStamp = 0u; diff --git a/editor/app/Features/Project/ProjectPanel.cpp b/editor/app/Features/Project/ProjectPanel.cpp index f1058679..b9658aae 100644 --- a/editor/app/Features/Project/ProjectPanel.cpp +++ b/editor/app/Features/Project/ProjectPanel.cpp @@ -237,7 +237,6 @@ int ResolveAssetGridColumnCount(float gridWidth) { namespace XCEngine::UI::Editor::App { -using ::XCEngine::Input::KeyCode; namespace GridDrag = XCEngine::UI::Editor::Collections::GridDragDrop; namespace TreeDrag = XCEngine::UI::Editor::Collections::TreeDragDrop; @@ -314,48 +313,6 @@ void AppendContextMenuSeparator( items.push_back(BuildContextMenuSeparatorItem(std::move(itemId))); } -std::size_t FindContextMenuItemIndexById( - const std::vector& items, - std::string_view itemId) { - for (std::size_t index = 0u; index < items.size(); ++index) { - if (items[index].itemId == itemId) { - return index; - } - } - - return Widgets::UIEditorMenuPopupInvalidIndex; -} - -Widgets::UIEditorMenuPopupState PreserveContextMenuWidgetState( - const std::vector& previousItems, - const Widgets::UIEditorMenuPopupState& previousState, - const std::vector& rebuiltItems) { - Widgets::UIEditorMenuPopupState preserved = {}; - preserved.focused = previousState.focused; - - if (previousState.hoveredIndex < previousItems.size()) { - const std::size_t hoveredIndex = - FindContextMenuItemIndexById( - rebuiltItems, - previousItems[previousState.hoveredIndex].itemId); - if (hoveredIndex < rebuiltItems.size() && rebuiltItems[hoveredIndex].enabled) { - preserved.hoveredIndex = hoveredIndex; - } - } - - if (previousState.submenuOpenIndex < previousItems.size()) { - const std::size_t submenuOpenIndex = - FindContextMenuItemIndexById( - rebuiltItems, - previousItems[previousState.submenuOpenIndex].itemId); - if (submenuOpenIndex < rebuiltItems.size() && rebuiltItems[submenuOpenIndex].enabled) { - preserved.submenuOpenIndex = submenuOpenIndex; - } - } - - return preserved; -} - } // namespace bool ProjectPanel::HasProjectRuntime() const { @@ -929,6 +886,7 @@ void ProjectPanel::OpenContextMenu( m_contextMenu.forceCurrentFolder = forceCurrentFolder; m_contextMenu.anchorPosition = anchorPosition; m_contextMenu.targetItemId = std::string(targetItemId); + ResetUIEditorMenuPopupInteractionState(m_contextMenu.interactionState); RebuildContextMenu(); } @@ -942,11 +900,6 @@ void ProjectPanel::RebuildContextMenu() { return; } - const std::vector previousItems = - m_contextMenu.items; - const Widgets::UIEditorMenuPopupState previousWidgetState = - m_contextMenu.widgetState; - const AssetCommandTarget assetTarget = ResolveAssetCommandTarget( m_contextMenu.targetItemId, @@ -1072,12 +1025,19 @@ void ProjectPanel::RebuildContextMenu() { placement.rect, m_contextMenu.items, popupMetrics); + m_contextMenu.interactionState.focused = true; m_contextMenu.widgetState = - PreserveContextMenuWidgetState( - previousItems, - previousWidgetState, - m_contextMenu.items); - m_contextMenu.widgetState.focused = true; + UpdateUIEditorMenuPopupInteraction( + m_contextMenu.interactionState, + UIEditorMenuPopupInteractionRequest{ + .layout = m_contextMenu.layout, + .items = m_contextMenu.items, + .preferredHighlightedItemId = {}, + .closeOnFocusLost = true, + .activateOnPointerDown = true, + }, + {}) + .popupState; } bool ProjectPanel::HandleContextMenuEvent(const UIInputEvent& event) { @@ -1092,21 +1052,6 @@ bool ProjectPanel::HandleContextMenuEvent(const UIInputEvent& event) { event.position); switch (event.type) { - case UIInputEventType::PointerMove: - case UIInputEventType::PointerEnter: - m_contextMenu.widgetState.hoveredIndex = - hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && - hitTarget.index < m_contextMenu.items.size() && - m_contextMenu.items[hitTarget.index].enabled - ? hitTarget.index - : Widgets::UIEditorMenuPopupInvalidIndex; - return hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None; - - case UIInputEventType::PointerLeave: - m_contextMenu.widgetState.hoveredIndex = - Widgets::UIEditorMenuPopupInvalidIndex; - return false; - case UIInputEventType::PointerButtonDown: if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right) { if (hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None) { @@ -1121,58 +1066,74 @@ bool ProjectPanel::HandleContextMenuEvent(const UIInputEvent& event) { return hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None; } - if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && - hitTarget.index < m_contextMenu.items.size() && - m_contextMenu.items[hitTarget.index].enabled) { - const std::string itemId = m_contextMenu.items[hitTarget.index].itemId; - CloseContextMenu(); - DispatchContextMenuItem(itemId); - return true; - } - - if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::PopupSurface) { - return true; - } - - CloseContextMenu(); - return true; - - case UIInputEventType::FocusLost: - CloseContextMenu(); - return false; - - case UIInputEventType::KeyDown: - if (event.keyCode == static_cast(KeyCode::Escape)) { + if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::None) { CloseContextMenu(); return true; } - return false; + + break; default: - return false; + break; } + + const UIEditorMenuPopupInteractionFrame popupFrame = + UpdateUIEditorMenuPopupInteraction( + m_contextMenu.interactionState, + UIEditorMenuPopupInteractionRequest{ + .layout = m_contextMenu.layout, + .items = m_contextMenu.items, + .preferredHighlightedItemId = {}, + .closeOnFocusLost = true, + .activateOnPointerDown = true, + }, + { event }); + m_contextMenu.widgetState = popupFrame.popupState; + + if (popupFrame.result.itemActivated) { + const std::string targetItemId = m_contextMenu.targetItemId; + const bool forceCurrentFolder = m_contextMenu.forceCurrentFolder; + const std::string activatedItemId = popupFrame.result.activatedItemId; + CloseContextMenu(); + DispatchContextMenuItem( + activatedItemId, + targetItemId, + forceCurrentFolder); + return true; + } + + if (popupFrame.result.closeRequested) { + const bool focusLost = event.type == UIInputEventType::FocusLost; + CloseContextMenu(); + return !focusLost; + } + + return popupFrame.result.consumed; } -bool ProjectPanel::DispatchContextMenuItem(std::string_view itemId) { +bool ProjectPanel::DispatchContextMenuItem( + std::string_view itemId, + std::string_view targetItemId, + bool forceCurrentFolder) { if (itemId == "project.context.open") { return OpenProjectItem( - m_contextMenu.targetItemId, + targetItemId, EventSource::GridSecondary); } if (itemId.rfind("assets.", 0u) == 0u) { return DispatchAssetCommand( itemId, - m_contextMenu.targetItemId, - m_contextMenu.forceCurrentFolder) + targetItemId, + forceCurrentFolder) .commandExecuted; } if (itemId.rfind("edit.", 0u) == 0u) { return DispatchEditCommand( itemId, - m_contextMenu.targetItemId, - m_contextMenu.forceCurrentFolder) + targetItemId, + forceCurrentFolder) .commandExecuted; } @@ -2059,6 +2020,10 @@ void ProjectPanel::Update( if (HandleContextMenuEvent(event)) { continue; } + if (m_contextMenu.open && + IsContentBlockedWhileContextMenuOpen(event.type)) { + continue; + } switch (event.type) { case UIInputEventType::FocusLost: diff --git a/editor/app/Features/Project/ProjectPanel.h b/editor/app/Features/Project/ProjectPanel.h index 353f7aa0..78682d21 100644 --- a/editor/app/Features/Project/ProjectPanel.h +++ b/editor/app/Features/Project/ProjectPanel.h @@ -11,7 +11,7 @@ #include #include #include -#include +#include #include #include @@ -137,6 +137,7 @@ private: std::vector items = {}; Widgets::UIEditorMenuPopupLayout layout = {}; Widgets::UIEditorMenuPopupState widgetState = {}; + UIEditorMenuPopupInteractionState interactionState = {}; }; struct BreadcrumbItemLayout { @@ -213,7 +214,10 @@ private: void CloseContextMenu(); void RebuildContextMenu(); bool HandleContextMenuEvent(const ::XCEngine::UI::UIInputEvent& event); - bool DispatchContextMenuItem(std::string_view itemId); + bool DispatchContextMenuItem( + std::string_view itemId, + std::string_view targetItemId, + bool forceCurrentFolder); void AppendContextMenu(::XCEngine::UI::UIDrawList& drawList) const; void ClearRenameState(); void SyncSelectionsFromRuntime(); diff --git a/editor/include/XCEditor/Foundation/UIEditorPanelInputFilter.h b/editor/include/XCEditor/Foundation/UIEditorPanelInputFilter.h index 71386ded..ed66b8cd 100644 --- a/editor/include/XCEditor/Foundation/UIEditorPanelInputFilter.h +++ b/editor/include/XCEditor/Foundation/UIEditorPanelInputFilter.h @@ -69,6 +69,7 @@ inline std::vector<::XCEngine::UI::UIInputEvent> FilterUIEditorPanelInputEvents( filteredEvents.reserve(inputEvents.size()); for (const UIInputEvent& event : inputEvents) { switch (event.type) { + case UIInputEventType::PointerEnter: case UIInputEventType::PointerMove: case UIInputEventType::PointerButtonDown: case UIInputEventType::PointerButtonUp: @@ -81,7 +82,8 @@ inline std::vector<::XCEngine::UI::UIInputEvent> FilterUIEditorPanelInputEvents( break; case UIInputEventType::PointerLeave: - if (options.includePointerLeave) { + if (options.includePointerLeave && + !isPointInAllowedPointerBounds(event.position)) { filteredEvents.push_back(event); } break; diff --git a/editor/include/XCEditor/Menu/UIEditorMenuPopupInteraction.h b/editor/include/XCEditor/Menu/UIEditorMenuPopupInteraction.h new file mode 100644 index 00000000..0210d048 --- /dev/null +++ b/editor/include/XCEditor/Menu/UIEditorMenuPopupInteraction.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include + +#include +#include +#include + +namespace XCEngine::UI { + +struct UIInputEvent; + +} // namespace XCEngine::UI + +namespace XCEngine::UI::Editor { + +struct UIEditorMenuPopupInteractionState { + std::string highlightedItemId = {}; + std::string pressedItemId = {}; + bool focused = false; +}; + +struct UIEditorMenuPopupInteractionRequest { + Widgets::UIEditorMenuPopupLayout layout = {}; + std::vector items = {}; + std::string preferredHighlightedItemId = {}; + bool closeOnFocusLost = true; + bool activateOnPointerDown = false; +}; + +struct UIEditorMenuPopupInteractionResult { + bool consumed = false; + bool highlightChanged = false; + bool closeRequested = false; + bool itemActivated = false; + std::size_t activatedIndex = Widgets::UIEditorMenuPopupInvalidIndex; + std::string activatedItemId = {}; +}; + +struct UIEditorMenuPopupInteractionFrame { + Widgets::UIEditorMenuPopupLayout layout = {}; + Widgets::UIEditorMenuPopupState popupState = {}; + UIEditorMenuPopupInteractionResult result = {}; +}; + +void ResetUIEditorMenuPopupInteractionState( + UIEditorMenuPopupInteractionState& state); + +UIEditorMenuPopupInteractionFrame UpdateUIEditorMenuPopupInteraction( + UIEditorMenuPopupInteractionState& state, + const UIEditorMenuPopupInteractionRequest& request, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents); + +} // namespace XCEngine::UI::Editor diff --git a/editor/src/Menu/UIEditorMenuPopupInteraction.cpp b/editor/src/Menu/UIEditorMenuPopupInteraction.cpp new file mode 100644 index 00000000..4c5248cb --- /dev/null +++ b/editor/src/Menu/UIEditorMenuPopupInteraction.cpp @@ -0,0 +1,380 @@ +#include + +#include + +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using ::XCEngine::Input::KeyCode; +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIPointerButton; + +bool IsPopupItemInteractive(const Widgets::UIEditorMenuPopupItem& item) { + return item.kind != UIEditorMenuItemKind::Separator && item.enabled; +} + +std::size_t FindPopupItemIndexById( + const std::vector& items, + std::string_view itemId) { + for (std::size_t index = 0u; index < items.size(); ++index) { + if (items[index].itemId == itemId) { + return index; + } + } + + return Widgets::UIEditorMenuPopupInvalidIndex; +} + +std::string ResolveCheckedPopupItemId( + const std::vector& items) { + for (const Widgets::UIEditorMenuPopupItem& item : items) { + if (item.checked && IsPopupItemInteractive(item)) { + return item.itemId; + } + } + + return {}; +} + +std::string ResolveFirstInteractivePopupItemId( + const std::vector& items) { + for (const Widgets::UIEditorMenuPopupItem& item : items) { + if (IsPopupItemInteractive(item)) { + return item.itemId; + } + } + + return {}; +} + +std::string ResolvePopupItemIdAtOffset( + const std::vector& items, + std::string_view currentItemId, + int delta) { + if (items.empty()) { + return {}; + } + + const std::size_t currentIndex = + FindPopupItemIndexById(items, currentItemId); + const int startIndex = currentIndex == Widgets::UIEditorMenuPopupInvalidIndex + ? (delta >= 0 ? -1 : static_cast(items.size())) + : static_cast(currentIndex); + const int step = delta >= 0 ? 1 : -1; + int remaining = delta >= 0 ? delta : -delta; + int index = startIndex; + while (remaining > 0) { + index += step; + while (index >= 0 && + index < static_cast(items.size()) && + !IsPopupItemInteractive(items[static_cast(index)])) { + index += step; + } + if (index < 0 || index >= static_cast(items.size())) { + return currentIndex == Widgets::UIEditorMenuPopupInvalidIndex + ? ResolveFirstInteractivePopupItemId(items) + : std::string(currentItemId); + } + --remaining; + } + + return index >= 0 && index < static_cast(items.size()) + ? items[static_cast(index)].itemId + : std::string(currentItemId); +} + +std::string ResolvePopupEdgeItemId( + const std::vector& items, + bool last) { + if (!last) { + return ResolveFirstInteractivePopupItemId(items); + } + + for (std::size_t index = items.size(); index > 0u; --index) { + if (IsPopupItemInteractive(items[index - 1u])) { + return items[index - 1u].itemId; + } + } + + return {}; +} + +std::string ResolvePreferredPopupItemId( + const UIEditorMenuPopupInteractionRequest& request) { + if (!request.preferredHighlightedItemId.empty()) { + const std::size_t preferredIndex = + FindPopupItemIndexById( + request.items, + request.preferredHighlightedItemId); + if (preferredIndex < request.items.size() && + IsPopupItemInteractive(request.items[preferredIndex])) { + return request.preferredHighlightedItemId; + } + } + + const std::string checkedItemId = ResolveCheckedPopupItemId(request.items); + if (!checkedItemId.empty()) { + return checkedItemId; + } + + return ResolveFirstInteractivePopupItemId(request.items); +} + +void SyncPopupInteractionState( + UIEditorMenuPopupInteractionState& state, + const UIEditorMenuPopupInteractionRequest& request) { + const std::size_t highlightedIndex = + FindPopupItemIndexById(request.items, state.highlightedItemId); + if (highlightedIndex >= request.items.size() || + !IsPopupItemInteractive(request.items[highlightedIndex])) { + state.highlightedItemId = ResolvePreferredPopupItemId(request); + } + + const std::size_t pressedIndex = + FindPopupItemIndexById(request.items, state.pressedItemId); + if (pressedIndex >= request.items.size() || + !IsPopupItemInteractive(request.items[pressedIndex])) { + state.pressedItemId.clear(); + } +} + +Widgets::UIEditorMenuPopupState BuildWidgetPopupState( + const UIEditorMenuPopupInteractionState& state, + const std::vector& items) { + Widgets::UIEditorMenuPopupState popupState = {}; + popupState.focused = state.focused; + popupState.hoveredIndex = + FindPopupItemIndexById(items, state.highlightedItemId); + return popupState; +} + +void SetHighlightedPopupItemId( + UIEditorMenuPopupInteractionState& state, + std::string itemId, + UIEditorMenuPopupInteractionResult& result) { + if (state.highlightedItemId != itemId) { + state.highlightedItemId = std::move(itemId); + result.highlightChanged = true; + } +} + +void ActivateHighlightedPopupItem( + UIEditorMenuPopupInteractionState& state, + const UIEditorMenuPopupInteractionRequest& request, + UIEditorMenuPopupInteractionResult& result) { + const std::size_t highlightedIndex = + FindPopupItemIndexById(request.items, state.highlightedItemId); + if (highlightedIndex >= request.items.size() || + !IsPopupItemInteractive(request.items[highlightedIndex])) { + return; + } + + result.itemActivated = true; + result.closeRequested = true; + result.consumed = true; + result.activatedIndex = highlightedIndex; + result.activatedItemId = request.items[highlightedIndex].itemId; +} + +void MergePopupInteractionResult( + UIEditorMenuPopupInteractionResult& accumulated, + UIEditorMenuPopupInteractionResult current) { + accumulated.consumed = accumulated.consumed || current.consumed; + accumulated.highlightChanged = + accumulated.highlightChanged || current.highlightChanged; + accumulated.closeRequested = + accumulated.closeRequested || current.closeRequested; + accumulated.itemActivated = + accumulated.itemActivated || current.itemActivated; + if (current.activatedIndex != Widgets::UIEditorMenuPopupInvalidIndex) { + accumulated.activatedIndex = current.activatedIndex; + } + if (!current.activatedItemId.empty()) { + accumulated.activatedItemId = std::move(current.activatedItemId); + } +} + +} // namespace + +void ResetUIEditorMenuPopupInteractionState( + UIEditorMenuPopupInteractionState& state) { + state = {}; +} + +UIEditorMenuPopupInteractionFrame UpdateUIEditorMenuPopupInteraction( + UIEditorMenuPopupInteractionState& state, + const UIEditorMenuPopupInteractionRequest& request, + const std::vector& inputEvents) { + UIEditorMenuPopupInteractionFrame frame = {}; + frame.layout = request.layout; + SyncPopupInteractionState(state, request); + + for (const UIInputEvent& event : inputEvents) { + UIEditorMenuPopupInteractionResult eventResult = {}; + const Widgets::UIEditorMenuPopupHitTarget hitTarget = + Widgets::HitTestUIEditorMenuPopup( + request.layout, + request.items, + event.position); + + switch (event.type) { + case UIInputEventType::FocusGained: + state.focused = true; + break; + + case UIInputEventType::FocusLost: + state.focused = false; + state.pressedItemId.clear(); + if (request.closeOnFocusLost) { + eventResult.closeRequested = true; + } + break; + + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + state.focused = true; + if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && + hitTarget.index < request.items.size() && + IsPopupItemInteractive(request.items[hitTarget.index])) { + SetHighlightedPopupItemId( + state, + request.items[hitTarget.index].itemId, + eventResult); + eventResult.consumed = true; + } else if (hitTarget.kind != + Widgets::UIEditorMenuPopupHitTargetKind::None) { + SetHighlightedPopupItemId(state, {}, eventResult); + eventResult.consumed = true; + } + break; + + case UIInputEventType::PointerLeave: + SetHighlightedPopupItemId(state, {}, eventResult); + state.pressedItemId.clear(); + break; + + case UIInputEventType::PointerButtonDown: + if (event.pointerButton != UIPointerButton::Left) { + break; + } + + if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && + hitTarget.index < request.items.size() && + IsPopupItemInteractive(request.items[hitTarget.index])) { + state.focused = true; + SetHighlightedPopupItemId( + state, + request.items[hitTarget.index].itemId, + eventResult); + if (request.activateOnPointerDown) { + state.pressedItemId.clear(); + ActivateHighlightedPopupItem(state, request, eventResult); + } else { + state.pressedItemId = request.items[hitTarget.index].itemId; + } + eventResult.consumed = true; + } else if (hitTarget.kind == + Widgets::UIEditorMenuPopupHitTargetKind::PopupSurface) { + state.focused = true; + state.pressedItemId.clear(); + eventResult.consumed = true; + } else { + state.pressedItemId.clear(); + } + break; + + case UIInputEventType::PointerButtonUp: + if (event.pointerButton != UIPointerButton::Left) { + break; + } + + if (!state.pressedItemId.empty() && + hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item && + hitTarget.index < request.items.size() && + request.items[hitTarget.index].itemId == state.pressedItemId && + IsPopupItemInteractive(request.items[hitTarget.index])) { + ActivateHighlightedPopupItem(state, request, eventResult); + } else if (hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None) { + eventResult.consumed = true; + } + + state.pressedItemId.clear(); + break; + + case UIInputEventType::KeyDown: + state.focused = true; + switch (static_cast(event.keyCode)) { + case KeyCode::Up: + SetHighlightedPopupItemId( + state, + ResolvePopupItemIdAtOffset( + request.items, + state.highlightedItemId, + -1), + eventResult); + eventResult.consumed = true; + break; + + case KeyCode::Down: + SetHighlightedPopupItemId( + state, + ResolvePopupItemIdAtOffset( + request.items, + state.highlightedItemId, + 1), + eventResult); + eventResult.consumed = true; + break; + + case KeyCode::Home: + SetHighlightedPopupItemId( + state, + ResolvePopupEdgeItemId(request.items, false), + eventResult); + eventResult.consumed = true; + break; + + case KeyCode::End: + SetHighlightedPopupItemId( + state, + ResolvePopupEdgeItemId(request.items, true), + eventResult); + eventResult.consumed = true; + break; + + case KeyCode::Enter: + case KeyCode::Space: + ActivateHighlightedPopupItem(state, request, eventResult); + break; + + case KeyCode::Escape: + eventResult.closeRequested = true; + eventResult.consumed = true; + break; + + default: + break; + } + break; + + default: + break; + } + + if (eventResult.consumed || + eventResult.highlightChanged || + eventResult.closeRequested || + eventResult.itemActivated) { + MergePopupInteractionResult(frame.result, std::move(eventResult)); + } + } + + frame.popupState = BuildWidgetPopupState(state, request.items); + return frame; +} + +} // namespace XCEngine::UI::Editor diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index 6a599f15..b7e20a02 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -6,6 +6,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_menu_session.cpp test_ui_editor_menu_bar.cpp test_ui_editor_menu_popup.cpp + test_ui_editor_menu_popup_interaction.cpp test_ui_editor_panel_content_host.cpp test_ui_editor_panel_host_lifecycle.cpp test_ui_editor_panel_input_filter.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_menu_popup_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_menu_popup_interaction.cpp new file mode 100644 index 00000000..36038f01 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_menu_popup_interaction.cpp @@ -0,0 +1,182 @@ +#include + +#include + +#include + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::ResetUIEditorMenuPopupInteractionState; +using XCEngine::UI::Editor::UIEditorMenuItemKind; +using XCEngine::UI::Editor::UIEditorMenuPopupInteractionRequest; +using XCEngine::UI::Editor::UIEditorMenuPopupInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorMenuPopupInteraction; +using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuPopupLayout; +using XCEngine::UI::Editor::Widgets::MeasureUIEditorMenuPopupHeight; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupItem; + +UIEditorMenuPopupItem MakeCommandItem( + std::string itemId, + std::string label, + bool enabled = true) { + UIEditorMenuPopupItem item = {}; + item.itemId = std::move(itemId); + item.kind = UIEditorMenuItemKind::Command; + item.label = std::move(label); + item.enabled = enabled; + return item; +} + +UIEditorMenuPopupItem MakeSeparatorItem(std::string itemId) { + UIEditorMenuPopupItem item = {}; + item.itemId = std::move(itemId); + item.kind = UIEditorMenuItemKind::Separator; + item.enabled = false; + return item; +} + +std::vector BuildPopupItems() { + return { + MakeCommandItem("open", "Open"), + MakeSeparatorItem("separator"), + MakeCommandItem("duplicate", "Duplicate", false), + MakeCommandItem("delete", "Delete") + }; +} + +UIInputEvent MakePointerEvent( + UIInputEventType type, + const UIPoint& position, + UIPointerButton button = UIPointerButton::Left) { + UIInputEvent event = {}; + event.type = type; + event.position = position; + event.pointerButton = button; + return event; +} + +UIInputEvent MakeKeyDown(KeyCode keyCode) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = static_cast(keyCode); + return event; +} + +UIInputEvent MakeFocusEvent(UIInputEventType type) { + UIInputEvent event = {}; + event.type = type; + return event; +} + +UIPoint RectCenter(const UIRect& rect) { + return UIPoint( + rect.x + rect.width * 0.5f, + rect.y + rect.height * 0.5f); +} + +} // namespace + +TEST(UIEditorMenuPopupInteractionTest, PointerHoverAndClickActivatesEnabledItem) { + const std::vector items = BuildPopupItems(); + const UIRect popupRect( + 20.0f, + 10.0f, + 180.0f, + MeasureUIEditorMenuPopupHeight(items)); + const auto layout = BuildUIEditorMenuPopupLayout(popupRect, items); + UIEditorMenuPopupInteractionState state = {}; + + const auto frame = UpdateUIEditorMenuPopupInteraction( + state, + UIEditorMenuPopupInteractionRequest{ + .layout = layout, + .items = items, + .preferredHighlightedItemId = {}, + .closeOnFocusLost = true, + }, + { + MakePointerEvent( + UIInputEventType::PointerMove, + RectCenter(layout.itemRects[0])), + MakePointerEvent( + UIInputEventType::PointerButtonDown, + RectCenter(layout.itemRects[0])), + MakePointerEvent( + UIInputEventType::PointerButtonUp, + RectCenter(layout.itemRects[0])) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.itemActivated); + EXPECT_TRUE(frame.result.closeRequested); + EXPECT_EQ(frame.result.activatedIndex, 0u); + EXPECT_EQ(frame.result.activatedItemId, "open"); + EXPECT_EQ(frame.popupState.hoveredIndex, 0u); +} + +TEST(UIEditorMenuPopupInteractionTest, KeyboardNavigationSkipsNonInteractiveItems) { + const std::vector items = BuildPopupItems(); + const UIRect popupRect( + 20.0f, + 10.0f, + 180.0f, + MeasureUIEditorMenuPopupHeight(items)); + const auto layout = BuildUIEditorMenuPopupLayout(popupRect, items); + UIEditorMenuPopupInteractionState state = {}; + state.focused = true; + + const auto frame = UpdateUIEditorMenuPopupInteraction( + state, + UIEditorMenuPopupInteractionRequest{ + .layout = layout, + .items = items, + .preferredHighlightedItemId = {}, + .closeOnFocusLost = true, + }, + { + MakeKeyDown(KeyCode::Down), + MakeKeyDown(KeyCode::Enter) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.itemActivated); + EXPECT_EQ(frame.result.activatedItemId, "delete"); + EXPECT_EQ(frame.popupState.hoveredIndex, 3u); +} + +TEST(UIEditorMenuPopupInteractionTest, FocusLostRequestsCloseAndClearsFocus) { + const std::vector items = BuildPopupItems(); + const UIRect popupRect( + 20.0f, + 10.0f, + 180.0f, + MeasureUIEditorMenuPopupHeight(items)); + const auto layout = BuildUIEditorMenuPopupLayout(popupRect, items); + UIEditorMenuPopupInteractionState state = {}; + state.focused = true; + + const auto frame = UpdateUIEditorMenuPopupInteraction( + state, + UIEditorMenuPopupInteractionRequest{ + .layout = layout, + .items = items, + .preferredHighlightedItemId = {}, + .closeOnFocusLost = true, + }, + { + MakeFocusEvent(UIInputEventType::FocusLost) + }); + + EXPECT_TRUE(frame.result.closeRequested); + EXPECT_FALSE(frame.popupState.focused); + + ResetUIEditorMenuPopupInteractionState(state); + EXPECT_FALSE(state.focused); + EXPECT_TRUE(state.highlightedItemId.empty()); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_panel_input_filter.cpp b/tests/UI/Editor/unit/test_ui_editor_panel_input_filter.cpp index 6bd2a3b6..0e18d661 100644 --- a/tests/UI/Editor/unit/test_ui_editor_panel_input_filter.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_panel_input_filter.cpp @@ -46,6 +46,20 @@ UIInputEvent MakePointerLeave() { return event; } +UIInputEvent MakePointerLeave(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerLeave; + event.position = UIPoint(x, y); + return event; +} + +UIInputEvent MakePointerEnter(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerEnter; + event.position = UIPoint(x, y); + return event; +} + } // namespace TEST(UIEditorPanelInputFilterTests, FiltersPointerByBoundsAndPreservesKeyboardAndFocusChannels) { @@ -57,7 +71,7 @@ TEST(UIEditorPanelInputFilterTests, FiltersPointerByBoundsAndPreservesKeyboardAn MakePointerButtonDown(140.0f, 20.0f), MakeKeyDown(), MakeFocusLost(), - MakePointerLeave() + MakePointerLeave(140.0f, 20.0f) }, UIEditorPanelInputFilterOptions{ .allowPointerInBounds = true, @@ -121,6 +135,32 @@ TEST(UIEditorPanelInputFilterTests, ExtraOverlayBoundsAllowPointerEventsOutsideP EXPECT_EQ(filtered[1].type, UIInputEventType::PointerButtonDown); } +TEST(UIEditorPanelInputFilterTests, OverlayBoundsKeepPointerEnterAndSuppressSyntheticLeave) { + const std::vector overlayBounds = { + UIRect(120.0f, 10.0f, 80.0f, 80.0f) + }; + const std::vector filtered = + FilterUIEditorPanelInputEvents( + UIRect(0.0f, 0.0f, 100.0f, 100.0f), + { + MakePointerLeave(130.0f, 20.0f), + MakePointerEnter(130.0f, 20.0f), + MakePointerMove(130.0f, 24.0f) + }, + UIEditorPanelInputFilterOptions{ + .allowPointerInBounds = true, + .allowPointerWhileCaptured = false, + .allowKeyboardInput = false, + .allowFocusEvents = false, + .includePointerLeave = true + }, + &overlayBounds); + + ASSERT_EQ(filtered.size(), 2u); + EXPECT_EQ(filtered[0].type, UIInputEventType::PointerEnter); + EXPECT_EQ(filtered[1].type, UIInputEventType::PointerMove); +} + TEST(UIEditorPanelInputFilterTests, SyntheticFocusTransitionsArePrependedToFilteredEvents) { const std::vector filtered = BuildUIEditorPanelInputEvents(