diff --git a/docs/plan/NewEditor_Tab脱离独立窗口重构计划_2026-04-14.md b/docs/plan/NewEditor_Tab脱离独立窗口重构计划_2026-04-14.md new file mode 100644 index 00000000..b28e6f92 --- /dev/null +++ b/docs/plan/NewEditor_Tab脱离独立窗口重构计划_2026-04-14.md @@ -0,0 +1,368 @@ +# NewEditor Tab 脱离独立窗口重构计划 + +日期: `2026-04-14` + +## 1. 目标 + +把 `new_editor` 当前“只能在主窗口内部重排 / dock”的 tab 系统,重构为支持: + +1. tab 从主窗口拖出后,生成独立窗口 +2. 独立窗口中的 tab 可以继续拖回主窗口 +3. 独立窗口之间可以继续相互 dock / 合并 +4. 现有单窗口 DockHost 布局与 panel 内容代码尽量保持复用 + +核心原则: + +1. 不在现有 `UIEditorWorkspaceNodeKind` 里硬塞 `FloatingWindow` +2. 保持“一棵 workspace 树只描述一个窗口内部的 dock 布局” +3. 浮动窗口是宿主层与窗口集合层的概念,不是 dock 树节点概念 +4. 先把结构做对,再接拖拽与窗口生命周期 + +## 2. 当前根因 + +当前不能把 tab 拖成独立窗口,不是交互细节 bug,而是架构缺层。 + +现状如下: + +1. `UIEditorWorkspaceModel` 只描述一棵窗口内 dock tree +2. `UIEditorDockHostInteraction` 只支持: + - `ReorderTab(...)` + - `MoveTabToStack(...)` + - `DockTabRelative(...)` +3. 指针离开 dock host 以后,只会清空 `dropPreview`,不会生成新的宿主窗口实体 +4. `Application` / `ProductEditorWorkspace` / `ProductEditorContext` 当前都只服务一个主窗口实例 + +所以必须先补上“窗口集合 + 每窗口独立 shell 状态 + 跨窗口拖拽转移”这一层。 + +## 3. 设计原则 + +### 3.1 保留单窗口 workspace 模型 + +保留当前这条边界: + +- `UIEditorWorkspaceModel` +- `UIEditorWorkspaceController` +- `UIEditorDockHost` +- `UIEditorDockHostInteraction` + +它们继续只处理“一个窗口内部”的 dock、split、tab、panel。 + +### 3.2 新增窗口集合层 + +新增一层更高的宿主模型,例如: + +- `UIEditorWindowWorkspaceState` +- `UIEditorWindowWorkspaceController` +- `UIEditorWindowWorkspaceSet` + +职责是: + +1. 持有主窗口 workspace +2. 持有多个 detached window workspace +3. 管理跨窗口 tab 转移 +4. 管理 detached window 的创建、关闭、回收 + +### 3.3 拖拽是全局会话,不是单窗口局部状态 + +当前 tab 拖拽状态挂在单个 `UIEditorDockHostInteractionState` 里,这只适用于单窗口。 + +重构后要拆成两层: + +1. 窗口内局部状态 + - tab strip hover / active / splitter drag +2. 跨窗口全局拖拽状态 + - 正在拖哪个 panel + - 来源窗口 id / 来源 node id + - 当前鼠标屏幕坐标 + - 当前命中的目标窗口 / 目标 tab stack + - 是否已经触发“脱离为新窗口” + +### 3.4 panel 内容与业务状态不跟随 HWND 绑死 + +`Hierarchy / Inspector / Project / Console / Scene / Game` 的内容逻辑仍然由 editor 业务层统一提供。 + +窗口只负责: + +1. 展示哪些 panel +2. 当前 panel 在这个窗口里的 dock 关系 +3. 这个窗口自己的输入、hover、capture、draw data + +## 4. 目标架构 + +## 4.1 数据层 + +新增建议: + +- `new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceModel.h` +- `new_editor/src/Shell/UIEditorWindowWorkspaceModel.cpp` + +建议结构: + +```cpp +struct UIEditorWindowWorkspaceState { + std::string windowId; + UIEditorWorkspaceModel workspace; +}; + +struct UIEditorWindowWorkspaceSet { + std::string primaryWindowId; + std::vector windows; + std::string activeWindowId; +}; +``` + +说明: + +1. 每个窗口持有一份自己的 `UIEditorWorkspaceModel` +2. 主窗口只是 `primaryWindowId` +3. detached window 只是 `windows` 里的非主窗口项 + +## 4.2 控制层 + +新增建议: + +- `new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceController.h` +- `new_editor/src/Shell/UIEditorWindowWorkspaceController.cpp` + +职责: + +1. 创建 detached window workspace +2. 销毁空窗口 +3. 把 panel 从源窗口移到目标窗口 +4. 把单 panel 初始化成新窗口根 tab stack +5. 支持: + - 同窗口重排 + - 跨窗口移入已有 stack + - 跨窗口 dock relative + - 拖出生成新窗口 + +这里不要复制已有 dock 逻辑,而是复用现有 `UIEditorWorkspaceController` 的布局变换能力。 + +## 4.3 交互层 + +新增建议: + +- `new_editor/include/XCEditor/Shell/UIEditorWindowDragSession.h` +- `new_editor/src/Shell/UIEditorWindowDragSession.cpp` + +全局拖拽状态建议: + +```cpp +struct UIEditorWindowTabDragSession { + bool active = false; + std::string sourceWindowId; + std::string sourceNodeId; + std::string sourcePanelId; + UIPoint screenPointerPosition = {}; + bool detachedWindowCreated = false; +}; +``` + +规则: + +1. 在某个窗口内开始 tab drag 时,建立全局 drag session +2. 鼠标还在某个 dock host 内时,按原有 drop preview 逻辑走 +3. 鼠标离开所有窗口且超过脱离阈值时,创建新 detached window +4. 新窗口创建后,把拖拽中的 panel 挂到这个窗口 +5. 后续拖拽继续以新窗口为目标窗口参与命中 + +## 4.4 宿主层 + +当前 `Application` 只对应一个 HWND,需要补成“主窗口 + detached 窗口宿主实例集合”。 + +建议新增: + +- `new_editor/app/Host/EditorHostWindow.h` +- `new_editor/app/Host/EditorHostWindow.cpp` +- `new_editor/app/Host/EditorHostWindowManager.h` +- `new_editor/app/Host/EditorHostWindowManager.cpp` + +职责拆分: + +### `EditorHostWindow` + +负责单个 HWND: + +1. Win32 消息 +2. 单窗口 render loop +3. 单窗口 workspace bounds +4. 单窗口 shell interaction / draw / cursor / capture + +### `EditorHostWindowManager` + +负责多窗口: + +1. 创建主窗口 +2. 创建 detached window +3. 维护 `windowId -> host window instance` +4. 在窗口关闭时回收 state +5. 协调全局 tab drag session + +## 5. 执行阶段 + +## 阶段 A:先补窗口集合模型,不改宿主 + +### 目标 + +先把“一个窗口集合里有多棵 workspace”建立起来,但仍然只跑主窗口。 + +### 任务 + +1. 新增 `UIEditorWindowWorkspaceSet` +2. 新增集合级 controller +3. 实现: + - `DetachPanelToNewWindow(...)` + - `MovePanelBetweenWindowsToStack(...)` + - `DockPanelBetweenWindowsRelative(...)` +4. 给这些操作补基础单元测试 + +### 验收 + +1. 不开第二个 HWND,也能在纯数据层完成 panel 跨窗口迁移 +2. 源窗口 panel 被正确移除 +3. 目标窗口 root 布局正确生成 + +## 阶段 B:把单窗口宿主抽成可复用的 window instance + +### 目标 + +把当前 `Application` 里只适用于单 HWND 的状态拆出来,为多窗口做准备。 + +### 任务 + +1. 提取单窗口运行时状态 +2. 提取单窗口 render/update/append/input 流程 +3. 让主窗口也走 `EditorHostWindow` + +### 验收 + +1. 主窗口行为不回退 +2. 代码里不再默认“全局只有一个 workspace / 一个 shell state / 一个 hwnd” + +## 阶段 C:接入 detached window 的 HWND 生命周期 + +### 目标 + +真正把第二个窗口创建出来,但先不接完整跨窗口拖拽。 + +### 任务 + +1. `EditorHostWindowManager` 支持创建第二个 `EditorHostWindow` +2. detached window 拥有自己的: + - HWND + - render loop + - interaction state + - draw pass +3. 空窗口关闭回收 + +### 验收 + +1. 可以通过代码直接创建一个带单 panel 的 detached window +2. 该窗口可独立显示、移动、关闭 + +## 阶段 D:接入 tab 拖出创建 detached window + +### 目标 + +把“拖出”真正打通。 + +### 任务 + +1. tab drag 启动时创建全局 drag session +2. 当指针离开全部 dock host 且满足阈值时: + - 从源窗口移除 panel + - 创建 detached window model + - 创建 detached HWND +3. 新窗口初始位置跟随拖拽起点与屏幕坐标 + +### 验收 + +1. 从主窗口拖出任意 tab,可变成独立窗口 +2. 不是复制,是迁移 +3. 原窗口布局自动 canonicalize,不留下空 stack / 空 split + +## 阶段 E:接入跨窗口 re-dock + +### 目标 + +支持 detached window 和主窗口双向回 dock。 + +### 任务 + +1. 命中其他窗口 dock host 时生成目标窗口 drop preview +2. pointer up 时执行跨窗口: + - move to stack + - dock relative +3. 如果源窗口被拖空,自动关闭该 detached window + +### 验收 + +1. detached -> main 可回 dock +2. detached -> detached 可互相 dock +3. 空窗口自动销毁,非空窗口保持 + +## 阶段 F:补交互与收口 + +### 目标 + +把功能补到可长期使用,不留明显交互破洞。 + +### 任务 + +1. 处理激活窗口切换 +2. 处理 capture / focus / cursor 所有权 +3. 处理窗口关闭时的 panel 回收策略 +4. 预留布局持久化结构 + +### 验收 + +1. 多窗口拖拽不闪退 +2. capture 不串窗口 +3. focus / active panel 行为稳定 + +## 6. 关键风险 + +### 6.1 不能让 panel 业务实例被窗口复制 + +如果直接复制 `ProductProjectPanel / ProductHierarchyPanel` 这类对象,很容易出现双状态漂移。 + +要先确认: + +1. 哪些状态是全局业务状态 +2. 哪些状态是窗口内展示状态 + +### 6.2 指针捕获要统一到宿主管理层 + +当前 capture 大多按单窗口处理。多窗口后如果还各管各的,很容易出现: + +1. 源窗口还认为自己在 drag +2. 目标窗口已经开始 preview +3. 鼠标松开后状态残留 + +所以 drag session 与 capture 仲裁必须提升到 window manager。 + +### 6.3 不能把跨窗口逻辑塞回 DockHostInteraction + +`UIEditorDockHostInteraction` 继续只处理单窗口内部命中与 preview。 + +跨窗口逻辑应放在更上层,否则很快会变成屎山。 + +## 7. 本轮执行顺序 + +这一轮按下面顺序推进: + +1. 阶段 A:补窗口集合模型与 controller +2. 阶段 B:提取单窗口 host instance +3. 阶段 C:先用代码路径创建 detached window,打通多 HWND +4. 阶段 D:接 tab 拖出创建新窗口 +5. 阶段 E:接跨窗口 re-dock + +## 8. 收口标准 + +满足以下条件,才算这次重构收口: + +1. tab 可以从主窗口拖出成为独立窗口 +2. 独立窗口可以拖回主窗口 +3. 独立窗口之间可以继续 dock +4. 不出现空窗口残留、空 stack 残留、拖拽状态残留 +5. 不破坏当前主窗口已有的 tab 重排 / split / dock 功能 diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 96a96853..34b5e4bf 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -89,6 +89,9 @@ set(XCUI_EDITOR_SHELL_SOURCES src/Shell/UIEditorWorkspaceLayoutPersistence.cpp src/Shell/UIEditorWorkspaceModel.cpp src/Shell/UIEditorWorkspaceSession.cpp + src/Shell/UIEditorWorkspaceTransfer.cpp + src/Shell/UIEditorWindowWorkspaceController.cpp + src/Shell/UIEditorWindowWorkspaceModel.cpp ) set(XCUI_EDITOR_WIDGET_SUPPORT_SOURCES diff --git a/new_editor/app/Application.cpp b/new_editor/app/Application.cpp index 90c02361..06c6f327 100644 --- a/new_editor/app/Application.cpp +++ b/new_editor/app/Application.cpp @@ -467,6 +467,7 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { "shell asset validation failed: " + m_editorContext.GetValidationMessage()); return false; } + m_workspaceController = m_editorContext.BuildWorkspaceController(); WNDCLASSEXW windowClass = {}; windowClass.cbSize = sizeof(windowClass); @@ -568,7 +569,9 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { LogRuntimeTrace( "app", "workspace initialized: " + - m_editorContext.DescribeWorkspaceState(m_editorWorkspace.GetShellInteractionState())); + m_editorContext.DescribeWorkspaceState( + m_workspaceController, + m_editorWorkspace.GetShellInteractionState())); m_renderReady = true; ShowWindow(m_hwnd, nCmdShow); @@ -639,6 +642,7 @@ void Application::RenderFrame() { "input", DescribeInputEvents(frameEvents) + " | " + m_editorContext.DescribeWorkspaceState( + m_workspaceController, m_editorWorkspace.GetShellInteractionState())); } @@ -650,6 +654,7 @@ void Application::RenderFrame() { m_editorWorkspace.Update( m_editorContext, + m_workspaceController, workspaceBounds, frameEvents, BuildCaptureStatusText()); @@ -667,7 +672,7 @@ void Application::RenderFrame() { << " commandExecuted=" << (shellFrame.result.workspaceResult.dockHostResult.commandExecuted ? "true" : "false") << " active=" - << m_editorContext.GetWorkspaceController().GetWorkspace().activePanelId + << m_workspaceController.GetWorkspace().activePanelId << " message=" << shellFrame.result.workspaceResult.dockHostResult.layoutResult.message; LogRuntimeTrace( diff --git a/new_editor/app/Application.h b/new_editor/app/Application.h index ee89df51..157dabe6 100644 --- a/new_editor/app/Application.h +++ b/new_editor/app/Application.h @@ -17,6 +17,7 @@ #include "Workspace/ProductEditorWorkspace.h" #include +#include #include #include @@ -126,6 +127,7 @@ private: ::XCEngine::UI::Editor::Host::AutoScreenshotController m_autoScreenshot = {}; ::XCEngine::UI::Editor::Host::InputModifierTracker m_inputModifierTracker = {}; App::ProductEditorContext m_editorContext = {}; + UIEditorWorkspaceController m_workspaceController = {}; App::ProductEditorWorkspace m_editorWorkspace = {}; std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {}; bool m_trackingMouseLeave = false; diff --git a/new_editor/app/Core/ProductEditorContext.cpp b/new_editor/app/Core/ProductEditorContext.cpp index 74df0c59..a7dac444 100644 --- a/new_editor/app/Core/ProductEditorContext.cpp +++ b/new_editor/app/Core/ProductEditorContext.cpp @@ -39,11 +39,6 @@ bool ProductEditorContext::Initialize(const std::filesystem::path& repoRoot) { m_session = {}; m_session.repoRoot = repoRoot; m_session.projectRoot = (repoRoot / "project").lexically_normal(); - m_workspaceController = UIEditorWorkspaceController( - m_shellAsset.panelRegistry, - m_shellAsset.workspace, - m_shellAsset.workspaceSession); - SyncSessionFromWorkspace(); m_hostCommandBridge.BindSession(m_session); m_shortcutManager = BuildEditorShellShortcutManager(m_shellAsset); m_shortcutManager.SetHostCommandHandler(&m_hostCommandBridge); @@ -63,8 +58,9 @@ void ProductEditorContext::SetExitRequestHandler(std::function handler) m_hostCommandBridge.SetExitRequestHandler(std::move(handler)); } -void ProductEditorContext::SyncSessionFromWorkspace() { - SyncProductEditorSessionFromWorkspace(m_session, m_workspaceController); +void ProductEditorContext::SyncSessionFromWorkspace( + const UIEditorWorkspaceController& workspaceController) { + SyncProductEditorSessionFromWorkspace(m_session, workspaceController); } bool ProductEditorContext::IsValid() const { @@ -91,12 +87,11 @@ void ProductEditorContext::ClearSelection() { m_session.selection = {}; } -UIEditorWorkspaceController& ProductEditorContext::GetWorkspaceController() { - return m_workspaceController; -} - -const UIEditorWorkspaceController& ProductEditorContext::GetWorkspaceController() const { - return m_workspaceController; +UIEditorWorkspaceController ProductEditorContext::BuildWorkspaceController() const { + return UIEditorWorkspaceController( + m_shellAsset.panelRegistry, + m_shellAsset.workspace, + m_shellAsset.workspaceSession); } const UIEditorShellInteractionServices& ProductEditorContext::GetShellServices() const { @@ -104,10 +99,11 @@ const UIEditorShellInteractionServices& ProductEditorContext::GetShellServices() } UIEditorShellInteractionDefinition ProductEditorContext::BuildShellDefinition( + const UIEditorWorkspaceController& workspaceController, std::string_view captureText) const { return BuildProductShellInteractionDefinition( m_shellAsset, - m_workspaceController, + workspaceController, ComposeStatusText(m_lastStatus, m_lastMessage), captureText); } @@ -127,6 +123,7 @@ void ProductEditorContext::SetStatus( } void ProductEditorContext::UpdateStatusFromShellResult( + const UIEditorWorkspaceController& workspaceController, const UIEditorShellInteractionResult& result) { if (result.commandDispatched) { SetStatus( @@ -197,13 +194,14 @@ void ProductEditorContext::AppendConsoleEntry( } std::string ProductEditorContext::DescribeWorkspaceState( + const UIEditorWorkspaceController& workspaceController, const UIEditorShellInteractionState& interactionState) const { std::ostringstream stream = {}; - stream << "active=" << m_workspaceController.GetWorkspace().activePanelId; + stream << "active=" << workspaceController.GetWorkspace().activePanelId; const auto visiblePanels = CollectUIEditorWorkspaceVisiblePanels( - m_workspaceController.GetWorkspace(), - m_workspaceController.GetSession()); + workspaceController.GetWorkspace(), + workspaceController.GetSession()); stream << " visible=["; for (std::size_t index = 0; index < visiblePanels.size(); ++index) { if (index > 0u) { diff --git a/new_editor/app/Core/ProductEditorContext.h b/new_editor/app/Core/ProductEditorContext.h index fc7cd121..5e7be258 100644 --- a/new_editor/app/Core/ProductEditorContext.h +++ b/new_editor/app/Core/ProductEditorContext.h @@ -20,7 +20,7 @@ public: bool Initialize(const std::filesystem::path& repoRoot); void AttachTextMeasurer(const UIEditorTextMeasurer& textMeasurer); void SetExitRequestHandler(std::function handler); - void SyncSessionFromWorkspace(); + void SyncSessionFromWorkspace(const UIEditorWorkspaceController& workspaceController); bool IsValid() const; const std::string& GetValidationMessage() const; @@ -29,17 +29,20 @@ public: void SetSelection(ProductEditorSelectionState selection); void ClearSelection(); - UIEditorWorkspaceController& GetWorkspaceController(); - const UIEditorWorkspaceController& GetWorkspaceController() const; + UIEditorWorkspaceController BuildWorkspaceController() const; const UIEditorShellInteractionServices& GetShellServices() const; UIEditorShellInteractionDefinition BuildShellDefinition( + const UIEditorWorkspaceController& workspaceController, std::string_view captureText) const; void SetReadyStatus(); void SetStatus(std::string status, std::string message); - void UpdateStatusFromShellResult(const UIEditorShellInteractionResult& result); + void UpdateStatusFromShellResult( + const UIEditorWorkspaceController& workspaceController, + const UIEditorShellInteractionResult& result); std::string DescribeWorkspaceState( + const UIEditorWorkspaceController& workspaceController, const UIEditorShellInteractionState& interactionState) const; private: @@ -47,7 +50,6 @@ private: EditorShellAsset m_shellAsset = {}; EditorShellAssetValidationResult m_shellValidation = {}; - UIEditorWorkspaceController m_workspaceController = {}; UIEditorShortcutManager m_shortcutManager = {}; UIEditorShellInteractionServices m_shellServices = {}; ProductEditorSession m_session = {}; diff --git a/new_editor/app/Workspace/ProductEditorWorkspace.cpp b/new_editor/app/Workspace/ProductEditorWorkspace.cpp index 03b4a13c..cf29a53c 100644 --- a/new_editor/app/Workspace/ProductEditorWorkspace.cpp +++ b/new_editor/app/Workspace/ProductEditorWorkspace.cpp @@ -250,13 +250,14 @@ void ProductEditorWorkspace::Shutdown() { void ProductEditorWorkspace::Update( ProductEditorContext& context, + UIEditorWorkspaceController& workspaceController, const ::XCEngine::UI::UIRect& bounds, const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, std::string_view captureText) { const auto& metrics = ResolveUIEditorShellInteractionMetrics(); - context.SyncSessionFromWorkspace(); + context.SyncSessionFromWorkspace(workspaceController); UIEditorShellInteractionDefinition definition = - context.BuildShellDefinition(captureText); + context.BuildShellDefinition(workspaceController, captureText); m_viewportHostService.BeginFrame(); definition.workspacePresentations = BuildWorkspacePresentations(definition); const std::vector shellEvents = @@ -266,7 +267,7 @@ void ProductEditorWorkspace::Update( m_shellFrame = UpdateUIEditorShellInteraction( m_shellInteractionState, - context.GetWorkspaceController(), + workspaceController, bounds, definition, shellEvents, @@ -279,11 +280,11 @@ void ProductEditorWorkspace::Update( inputEvents, shellOwnsHostedContentPointerStream); ApplyViewportFramesToShellFrame(m_shellFrame, m_viewportHostService); - context.SyncSessionFromWorkspace(); - context.UpdateStatusFromShellResult(m_shellFrame.result); + context.SyncSessionFromWorkspace(workspaceController); + context.UpdateStatusFromShellResult(workspaceController, m_shellFrame.result); const std::string& activePanelId = - context.GetWorkspaceController().GetWorkspace().activePanelId; + workspaceController.GetWorkspace().activePanelId; m_hierarchyPanel.Update( m_shellFrame.workspaceInteractionFrame.composeFrame.contentHostFrame, hostedContentEvents, diff --git a/new_editor/app/Workspace/ProductEditorWorkspace.h b/new_editor/app/Workspace/ProductEditorWorkspace.h index ebd61752..c1408592 100644 --- a/new_editor/app/Workspace/ProductEditorWorkspace.h +++ b/new_editor/app/Workspace/ProductEditorWorkspace.h @@ -35,6 +35,7 @@ public: void Update( ProductEditorContext& context, + UIEditorWorkspaceController& workspaceController, const ::XCEngine::UI::UIRect& bounds, const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, std::string_view captureText); diff --git a/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h b/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h index 6da8711f..7c6feec7 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h +++ b/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h @@ -31,10 +31,13 @@ struct UIEditorDockHostInteractionResult { bool consumed = false; bool commandExecuted = false; bool layoutChanged = false; + bool detachRequested = false; bool requestPointerCapture = false; bool releasePointerCapture = false; Widgets::UIEditorDockHostHitTarget hitTarget = {}; std::string activeSplitterNodeId = {}; + std::string detachedNodeId = {}; + std::string detachedPanelId = {}; UIEditorWorkspaceCommandResult commandResult = {}; UIEditorWorkspaceLayoutOperationResult layoutResult = {}; }; diff --git a/new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceController.h b/new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceController.h new file mode 100644 index 00000000..ac4e1932 --- /dev/null +++ b/new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceController.h @@ -0,0 +1,91 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace XCEngine::UI::Editor { + +enum class UIEditorWindowWorkspaceOperationStatus : std::uint8_t { + Changed = 0, + NoOp, + Rejected +}; + +struct UIEditorWindowWorkspaceOperationResult { + UIEditorWindowWorkspaceOperationStatus status = + UIEditorWindowWorkspaceOperationStatus::Rejected; + std::string message = {}; + std::string sourceWindowId = {}; + std::string targetWindowId = {}; + std::string panelId = {}; + std::string activeWindowId = {}; + std::vector windowIds = {}; +}; + +std::string_view GetUIEditorWindowWorkspaceOperationStatusName( + UIEditorWindowWorkspaceOperationStatus status); + +class UIEditorWindowWorkspaceController { +public: + UIEditorWindowWorkspaceController() = default; + UIEditorWindowWorkspaceController( + UIEditorPanelRegistry panelRegistry, + UIEditorWindowWorkspaceSet windowSet); + + const UIEditorPanelRegistry& GetPanelRegistry() const { + return m_panelRegistry; + } + + const UIEditorWindowWorkspaceSet& GetWindowSet() const { + return m_windowSet; + } + + UIEditorWindowWorkspaceValidationResult ValidateState() const; + + UIEditorWindowWorkspaceOperationResult DetachPanelToNewWindow( + std::string_view sourceWindowId, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view preferredNewWindowId = {}); + + UIEditorWindowWorkspaceOperationResult MovePanelToStack( + std::string_view sourceWindowId, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetWindowId, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex); + + UIEditorWindowWorkspaceOperationResult DockPanelRelative( + std::string_view sourceWindowId, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetWindowId, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio = 0.5f); + +private: + UIEditorWindowWorkspaceOperationResult BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus status, + std::string message, + std::string_view sourceWindowId, + std::string_view targetWindowId, + std::string_view panelId) const; + + std::string MakeUniqueWindowId(std::string_view base) const; + + UIEditorPanelRegistry m_panelRegistry = {}; + UIEditorWindowWorkspaceSet m_windowSet = {}; +}; + +UIEditorWindowWorkspaceController BuildDefaultUIEditorWindowWorkspaceController( + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + std::string primaryWindowId = "main-window"); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceModel.h b/new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceModel.h new file mode 100644 index 00000000..b743083c --- /dev/null +++ b/new_editor/include/XCEditor/Shell/UIEditorWindowWorkspaceModel.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorWindowWorkspaceState { + std::string windowId = {}; + UIEditorWorkspaceModel workspace = {}; + UIEditorWorkspaceSession session = {}; +}; + +struct UIEditorWindowWorkspaceSet { + std::string primaryWindowId = {}; + std::string activeWindowId = {}; + std::vector windows = {}; +}; + +enum class UIEditorWindowWorkspaceValidationCode : std::uint8_t { + None = 0, + InvalidPanelRegistry, + EmptyWindowId, + DuplicateWindowId, + MissingPrimaryWindow, + MissingActiveWindow, + InvalidWorkspace, + InvalidSession +}; + +struct UIEditorWindowWorkspaceValidationResult { + UIEditorWindowWorkspaceValidationCode code = UIEditorWindowWorkspaceValidationCode::None; + std::string message = {}; + + [[nodiscard]] bool IsValid() const { + return code == UIEditorWindowWorkspaceValidationCode::None; + } +}; + +UIEditorWindowWorkspaceSet BuildDefaultUIEditorWindowWorkspaceSet( + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + std::string primaryWindowId = "main-window"); + +const UIEditorWindowWorkspaceState* FindUIEditorWindowWorkspaceState( + const UIEditorWindowWorkspaceSet& windowSet, + std::string_view windowId); + +UIEditorWindowWorkspaceState* FindMutableUIEditorWindowWorkspaceState( + UIEditorWindowWorkspaceSet& windowSet, + std::string_view windowId); + +UIEditorWindowWorkspaceValidationResult ValidateUIEditorWindowWorkspaceSet( + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWindowWorkspaceSet& windowSet); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h index c6e72434..97dd0f6e 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h +++ b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h @@ -150,6 +150,28 @@ bool TryReorderUIEditorWorkspaceTab( std::string_view panelId, std::size_t targetVisibleInsertionIndex); +bool TryExtractUIEditorWorkspaceVisiblePanelNode( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + UIEditorWorkspaceNode& extractedPanel); + +bool TryInsertUIEditorWorkspacePanelNodeToStack( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + UIEditorWorkspaceNode panelNode, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex); + +bool TryDockUIEditorWorkspacePanelNodeRelative( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + UIEditorWorkspaceNode panelNode, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio = 0.5f); + bool TryMoveUIEditorWorkspaceTabToStack( UIEditorWorkspaceModel& workspace, const UIEditorWorkspaceSession& session, diff --git a/new_editor/include/XCEditor/Shell/UIEditorWorkspaceTransfer.h b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceTransfer.h new file mode 100644 index 00000000..fe783ba8 --- /dev/null +++ b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceTransfer.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorWorkspaceExtractedPanel { + UIEditorWorkspaceNode panelNode = {}; + UIEditorPanelSessionState sessionState = {}; +}; + +bool TryExtractUIEditorWorkspaceVisiblePanel( + UIEditorWorkspaceModel& workspace, + UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + UIEditorWorkspaceExtractedPanel& extractedPanel); + +UIEditorWorkspaceModel BuildUIEditorDetachedWorkspaceFromExtractedPanel( + std::string rootNodeId, + UIEditorWorkspaceExtractedPanel extractedPanel); + +UIEditorWorkspaceSession BuildUIEditorDetachedWorkspaceSessionFromExtractedPanel( + UIEditorWorkspaceExtractedPanel extractedPanel); + +bool TryInsertExtractedUIEditorWorkspacePanelToStack( + UIEditorWorkspaceModel& workspace, + UIEditorWorkspaceSession& session, + UIEditorWorkspaceExtractedPanel extractedPanel, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex); + +bool TryDockExtractedUIEditorWorkspacePanelRelative( + UIEditorWorkspaceModel& workspace, + UIEditorWorkspaceSession& session, + UIEditorWorkspaceExtractedPanel extractedPanel, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio = 0.5f); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Shell/UIEditorDockHostInteraction.cpp b/new_editor/src/Shell/UIEditorDockHostInteraction.cpp index db593cd5..d2c82c7a 100644 --- a/new_editor/src/Shell/UIEditorDockHostInteraction.cpp +++ b/new_editor/src/Shell/UIEditorDockHostInteraction.cpp @@ -781,6 +781,21 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( break; } + if (!state.activeTabDragNodeId.empty() && + !state.activeTabDragPanelId.empty() && + state.hasPointerPosition && + !IsPointInsideRect(layout.bounds, state.pointerPosition)) { + eventResult.detachRequested = true; + eventResult.consumed = true; + eventResult.detachedNodeId = state.activeTabDragNodeId; + eventResult.detachedPanelId = state.activeTabDragPanelId; + eventResult.releasePointerCapture = + eventResult.releasePointerCapture || tabStripResult.releasePointerCapture; + ClearTabDockDragState(state); + state.dockHostState.focused = true; + break; + } + if (tabStripResult.dragEnded || tabStripResult.dragCanceled) { eventResult.consumed = tabStripResult.consumed; eventResult.hitTarget = tabStripResult.hitTarget; diff --git a/new_editor/src/Shell/UIEditorWindowWorkspaceController.cpp b/new_editor/src/Shell/UIEditorWindowWorkspaceController.cpp new file mode 100644 index 00000000..28c39d0b --- /dev/null +++ b/new_editor/src/Shell/UIEditorWindowWorkspaceController.cpp @@ -0,0 +1,446 @@ +#include + +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +namespace { + +bool IsSinglePanelRootWindow( + const UIEditorWindowWorkspaceState& state, + std::string_view panelId) { + const UIEditorWorkspaceNode& root = state.workspace.root; + return root.kind == UIEditorWorkspaceNodeKind::TabStack && + root.children.size() == 1u && + root.children.front().kind == UIEditorWorkspaceNodeKind::Panel && + root.children.front().panel.panelId == panelId && + state.session.panelStates.size() == 1u && + state.session.panelStates.front().panelId == panelId; +} + +bool TryExtractPanelFromWindow( + UIEditorWindowWorkspaceSet& windowSet, + std::string_view sourceWindowId, + std::string_view primaryWindowId, + std::string_view sourceNodeId, + std::string_view panelId, + UIEditorWorkspaceExtractedPanel& extractedPanel) { + UIEditorWindowWorkspaceState* sourceWindow = + FindMutableUIEditorWindowWorkspaceState(windowSet, sourceWindowId); + if (sourceWindow == nullptr) { + return false; + } + + if (sourceWindowId != primaryWindowId && + IsSinglePanelRootWindow(*sourceWindow, panelId)) { + extractedPanel.panelNode = std::move(sourceWindow->workspace.root.children.front()); + extractedPanel.sessionState = sourceWindow->session.panelStates.front(); + windowSet.windows.erase( + std::remove_if( + windowSet.windows.begin(), + windowSet.windows.end(), + [sourceWindowId](const UIEditorWindowWorkspaceState& state) { + return state.windowId == sourceWindowId; + }), + windowSet.windows.end()); + return true; + } + + return TryExtractUIEditorWorkspaceVisiblePanel( + sourceWindow->workspace, + sourceWindow->session, + sourceNodeId, + panelId, + extractedPanel); +} + +} // namespace + +std::string_view GetUIEditorWindowWorkspaceOperationStatusName( + UIEditorWindowWorkspaceOperationStatus status) { + switch (status) { + case UIEditorWindowWorkspaceOperationStatus::Changed: + return "Changed"; + case UIEditorWindowWorkspaceOperationStatus::NoOp: + return "NoOp"; + case UIEditorWindowWorkspaceOperationStatus::Rejected: + return "Rejected"; + } + + return "Unknown"; +} + +UIEditorWindowWorkspaceController::UIEditorWindowWorkspaceController( + UIEditorPanelRegistry panelRegistry, + UIEditorWindowWorkspaceSet windowSet) + : m_panelRegistry(std::move(panelRegistry)) + , m_windowSet(std::move(windowSet)) { +} + +UIEditorWindowWorkspaceValidationResult UIEditorWindowWorkspaceController::ValidateState() const { + return ValidateUIEditorWindowWorkspaceSet(m_panelRegistry, m_windowSet); +} + +UIEditorWindowWorkspaceOperationResult UIEditorWindowWorkspaceController::BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus status, + std::string message, + std::string_view sourceWindowId, + std::string_view targetWindowId, + std::string_view panelId) const { + UIEditorWindowWorkspaceOperationResult result = {}; + result.status = status; + result.message = std::move(message); + result.sourceWindowId = std::string(sourceWindowId); + result.targetWindowId = std::string(targetWindowId); + result.panelId = std::string(panelId); + result.activeWindowId = m_windowSet.activeWindowId; + result.windowIds.reserve(m_windowSet.windows.size()); + for (const UIEditorWindowWorkspaceState& state : m_windowSet.windows) { + result.windowIds.push_back(state.windowId); + } + return result; +} + +std::string UIEditorWindowWorkspaceController::MakeUniqueWindowId(std::string_view base) const { + std::string resolvedBase = base.empty() + ? std::string("detached-window") + : std::string(base); + if (FindUIEditorWindowWorkspaceState(m_windowSet, resolvedBase) == nullptr) { + return resolvedBase; + } + + for (std::size_t suffix = 1u; suffix < 1024u; ++suffix) { + const std::string candidate = resolvedBase + "-" + std::to_string(suffix); + if (FindUIEditorWindowWorkspaceState(m_windowSet, candidate) == nullptr) { + return candidate; + } + } + + return resolvedBase + "-overflow"; +} + +UIEditorWindowWorkspaceOperationResult UIEditorWindowWorkspaceController::DetachPanelToNewWindow( + std::string_view sourceWindowId, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view preferredNewWindowId) { + const UIEditorWindowWorkspaceValidationResult stateValidation = ValidateState(); + if (!stateValidation.IsValid()) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Window workspace state invalid: " + stateValidation.message, + sourceWindowId, + {}, + panelId); + } + + const UIEditorWindowWorkspaceState* sourceWindow = + FindUIEditorWindowWorkspaceState(m_windowSet, sourceWindowId); + if (sourceWindow == nullptr) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Source window not found.", + sourceWindowId, + {}, + panelId); + } + + if (sourceWindowId != m_windowSet.primaryWindowId && + IsSinglePanelRootWindow(*sourceWindow, panelId)) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::NoOp, + "Panel already occupies its own detached window.", + sourceWindowId, + sourceWindowId, + panelId); + } + + const UIEditorWindowWorkspaceSet windowSetBefore = m_windowSet; + UIEditorWorkspaceExtractedPanel extractedPanel = {}; + if (!TryExtractPanelFromWindow( + m_windowSet, + sourceWindowId, + m_windowSet.primaryWindowId, + sourceNodeId, + panelId, + extractedPanel)) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Failed to extract panel from source window.", + sourceWindowId, + {}, + panelId); + } + + const std::string newWindowId = MakeUniqueWindowId( + preferredNewWindowId.empty() + ? std::string(panelId) + "-window" + : std::string(preferredNewWindowId)); + UIEditorWindowWorkspaceState detachedWindow = {}; + detachedWindow.windowId = newWindowId; + detachedWindow.workspace = + BuildUIEditorDetachedWorkspaceFromExtractedPanel( + newWindowId + "-root", + extractedPanel); + detachedWindow.session = + BuildUIEditorDetachedWorkspaceSessionFromExtractedPanel(extractedPanel); + m_windowSet.windows.push_back(std::move(detachedWindow)); + m_windowSet.activeWindowId = newWindowId; + + const UIEditorWindowWorkspaceValidationResult validation = ValidateState(); + if (!validation.IsValid()) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Detach produced invalid state: " + validation.message, + sourceWindowId, + newWindowId, + panelId); + } + + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Changed, + "Panel detached into a new window.", + sourceWindowId, + newWindowId, + panelId); +} + +UIEditorWindowWorkspaceOperationResult UIEditorWindowWorkspaceController::MovePanelToStack( + std::string_view sourceWindowId, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetWindowId, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex) { + const UIEditorWindowWorkspaceValidationResult stateValidation = ValidateState(); + if (!stateValidation.IsValid()) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Window workspace state invalid: " + stateValidation.message, + sourceWindowId, + targetWindowId, + panelId); + } + + if (sourceWindowId == targetWindowId) { + UIEditorWindowWorkspaceState* window = + FindMutableUIEditorWindowWorkspaceState(m_windowSet, sourceWindowId); + if (window == nullptr) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Source window not found.", + sourceWindowId, + targetWindowId, + panelId); + } + + if (!TryMoveUIEditorWorkspaceTabToStack( + window->workspace, + window->session, + sourceNodeId, + panelId, + targetNodeId, + targetVisibleInsertionIndex)) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Move operation rejected by the workspace model.", + sourceWindowId, + targetWindowId, + panelId); + } + + m_windowSet.activeWindowId = std::string(targetWindowId); + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Changed, + "Panel moved within the same window.", + sourceWindowId, + targetWindowId, + panelId); + } + + const UIEditorWindowWorkspaceSet windowSetBefore = m_windowSet; + UIEditorWorkspaceExtractedPanel extractedPanel = {}; + if (!TryExtractPanelFromWindow( + m_windowSet, + sourceWindowId, + m_windowSet.primaryWindowId, + sourceNodeId, + panelId, + extractedPanel)) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Failed to extract panel from source window.", + sourceWindowId, + targetWindowId, + panelId); + } + + UIEditorWindowWorkspaceState* targetWindow = + FindMutableUIEditorWindowWorkspaceState(m_windowSet, targetWindowId); + if (targetWindow == nullptr || + !TryInsertExtractedUIEditorWorkspacePanelToStack( + targetWindow->workspace, + targetWindow->session, + std::move(extractedPanel), + targetNodeId, + targetVisibleInsertionIndex)) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Failed to insert the extracted panel into the target stack.", + sourceWindowId, + targetWindowId, + panelId); + } + + m_windowSet.activeWindowId = std::string(targetWindowId); + const UIEditorWindowWorkspaceValidationResult validation = ValidateState(); + if (!validation.IsValid()) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Move produced invalid state: " + validation.message, + sourceWindowId, + targetWindowId, + panelId); + } + + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Changed, + "Panel moved across windows.", + sourceWindowId, + targetWindowId, + panelId); +} + +UIEditorWindowWorkspaceOperationResult UIEditorWindowWorkspaceController::DockPanelRelative( + std::string_view sourceWindowId, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetWindowId, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio) { + const UIEditorWindowWorkspaceValidationResult stateValidation = ValidateState(); + if (!stateValidation.IsValid()) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Window workspace state invalid: " + stateValidation.message, + sourceWindowId, + targetWindowId, + panelId); + } + + if (sourceWindowId == targetWindowId) { + UIEditorWindowWorkspaceState* window = + FindMutableUIEditorWindowWorkspaceState(m_windowSet, sourceWindowId); + if (window == nullptr) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Source window not found.", + sourceWindowId, + targetWindowId, + panelId); + } + + if (!TryDockUIEditorWorkspaceTabRelative( + window->workspace, + window->session, + sourceNodeId, + panelId, + targetNodeId, + placement, + splitRatio)) { + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Dock operation rejected by the workspace model.", + sourceWindowId, + targetWindowId, + panelId); + } + + m_windowSet.activeWindowId = std::string(targetWindowId); + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Changed, + "Panel docked within the same window.", + sourceWindowId, + targetWindowId, + panelId); + } + + const UIEditorWindowWorkspaceSet windowSetBefore = m_windowSet; + UIEditorWorkspaceExtractedPanel extractedPanel = {}; + if (!TryExtractPanelFromWindow( + m_windowSet, + sourceWindowId, + m_windowSet.primaryWindowId, + sourceNodeId, + panelId, + extractedPanel)) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Failed to extract panel from source window.", + sourceWindowId, + targetWindowId, + panelId); + } + + UIEditorWindowWorkspaceState* targetWindow = + FindMutableUIEditorWindowWorkspaceState(m_windowSet, targetWindowId); + if (targetWindow == nullptr || + !TryDockExtractedUIEditorWorkspacePanelRelative( + targetWindow->workspace, + targetWindow->session, + std::move(extractedPanel), + targetNodeId, + placement, + splitRatio)) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Failed to dock the extracted panel into the target window.", + sourceWindowId, + targetWindowId, + panelId); + } + + m_windowSet.activeWindowId = std::string(targetWindowId); + const UIEditorWindowWorkspaceValidationResult validation = ValidateState(); + if (!validation.IsValid()) { + m_windowSet = windowSetBefore; + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Rejected, + "Dock produced invalid state: " + validation.message, + sourceWindowId, + targetWindowId, + panelId); + } + + return BuildOperationResult( + UIEditorWindowWorkspaceOperationStatus::Changed, + "Panel docked across windows.", + sourceWindowId, + targetWindowId, + panelId); +} + +UIEditorWindowWorkspaceController BuildDefaultUIEditorWindowWorkspaceController( + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + std::string primaryWindowId) { + return UIEditorWindowWorkspaceController( + panelRegistry, + BuildDefaultUIEditorWindowWorkspaceSet( + panelRegistry, + workspace, + std::move(primaryWindowId))); +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Shell/UIEditorWindowWorkspaceModel.cpp b/new_editor/src/Shell/UIEditorWindowWorkspaceModel.cpp new file mode 100644 index 00000000..d0e1e21c --- /dev/null +++ b/new_editor/src/Shell/UIEditorWindowWorkspaceModel.cpp @@ -0,0 +1,145 @@ +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +namespace { + +UIEditorWindowWorkspaceValidationResult MakeValidationError( + UIEditorWindowWorkspaceValidationCode code, + std::string message) { + UIEditorWindowWorkspaceValidationResult result = {}; + result.code = code; + result.message = std::move(message); + return result; +} + +} // namespace + +UIEditorWindowWorkspaceSet BuildDefaultUIEditorWindowWorkspaceSet( + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + std::string primaryWindowId) { + if (primaryWindowId.empty()) { + primaryWindowId = "main-window"; + } + + UIEditorWindowWorkspaceSet windowSet = {}; + windowSet.primaryWindowId = primaryWindowId; + windowSet.activeWindowId = primaryWindowId; + + UIEditorWindowWorkspaceState state = {}; + state.windowId = primaryWindowId; + state.workspace = CanonicalizeUIEditorWorkspaceModel(workspace); + state.session = BuildDefaultUIEditorWorkspaceSession(panelRegistry, state.workspace); + windowSet.windows.push_back(std::move(state)); + return windowSet; +} + +const UIEditorWindowWorkspaceState* FindUIEditorWindowWorkspaceState( + const UIEditorWindowWorkspaceSet& windowSet, + std::string_view windowId) { + for (const UIEditorWindowWorkspaceState& state : windowSet.windows) { + if (state.windowId == windowId) { + return &state; + } + } + + return nullptr; +} + +UIEditorWindowWorkspaceState* FindMutableUIEditorWindowWorkspaceState( + UIEditorWindowWorkspaceSet& windowSet, + std::string_view windowId) { + for (UIEditorWindowWorkspaceState& state : windowSet.windows) { + if (state.windowId == windowId) { + return &state; + } + } + + return nullptr; +} + +UIEditorWindowWorkspaceValidationResult ValidateUIEditorWindowWorkspaceSet( + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWindowWorkspaceSet& windowSet) { + const UIEditorPanelRegistryValidationResult registryValidation = + ValidateUIEditorPanelRegistry(panelRegistry); + if (!registryValidation.IsValid()) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::InvalidPanelRegistry, + registryValidation.message); + } + + if (windowSet.primaryWindowId.empty()) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::MissingPrimaryWindow, + "Primary window id must not be empty."); + } + + if (windowSet.activeWindowId.empty()) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::MissingActiveWindow, + "Active window id must not be empty."); + } + + std::unordered_set seenWindowIds = {}; + bool hasPrimaryWindow = false; + bool hasActiveWindow = false; + for (const UIEditorWindowWorkspaceState& state : windowSet.windows) { + if (state.windowId.empty()) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::EmptyWindowId, + "Window id must not be empty."); + } + + if (!seenWindowIds.insert(state.windowId).second) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::DuplicateWindowId, + "Window id '" + state.windowId + "' is duplicated."); + } + + if (state.windowId == windowSet.primaryWindowId) { + hasPrimaryWindow = true; + } + if (state.windowId == windowSet.activeWindowId) { + hasActiveWindow = true; + } + + const UIEditorWorkspaceValidationResult workspaceValidation = + ValidateUIEditorWorkspace(state.workspace); + if (!workspaceValidation.IsValid()) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::InvalidWorkspace, + "Window '" + state.windowId + "' workspace invalid: " + + workspaceValidation.message); + } + + const UIEditorWorkspaceSessionValidationResult sessionValidation = + ValidateUIEditorWorkspaceSession(panelRegistry, state.workspace, state.session); + if (!sessionValidation.IsValid()) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::InvalidSession, + "Window '" + state.windowId + "' session invalid: " + + sessionValidation.message); + } + } + + if (!hasPrimaryWindow) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::MissingPrimaryWindow, + "Primary window '" + windowSet.primaryWindowId + "' does not exist."); + } + + if (!hasActiveWindow) { + return MakeValidationError( + UIEditorWindowWorkspaceValidationCode::MissingActiveWindow, + "Active window '" + windowSet.activeWindowId + "' does not exist."); + } + + return {}; +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Shell/UIEditorWorkspaceModel.cpp b/new_editor/src/Shell/UIEditorWorkspaceModel.cpp index e1894df8..97e09229 100644 --- a/new_editor/src/Shell/UIEditorWorkspaceModel.cpp +++ b/new_editor/src/Shell/UIEditorWorkspaceModel.cpp @@ -839,6 +839,145 @@ bool TryReorderUIEditorWorkspaceTab( return true; } +bool TryExtractUIEditorWorkspaceVisiblePanelNode( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + UIEditorWorkspaceNode& extractedPanel) { + return TryExtractVisiblePanelFromTabStack( + workspace, + session, + sourceNodeId, + panelId, + extractedPanel); +} + +bool TryInsertUIEditorWorkspacePanelNodeToStack( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + UIEditorWorkspaceNode panelNode, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex) { + if (targetNodeId.empty() || + panelNode.kind != UIEditorWorkspaceNodeKind::Panel || + panelNode.panel.panelId.empty()) { + return false; + } + + const UIEditorWorkspaceNode* targetNode = + FindUIEditorWorkspaceNode(workspace, targetNodeId); + if (targetNode == nullptr || + targetNode->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + if (targetVisibleInsertionIndex > CountVisibleChildren(*targetNode, session)) { + return false; + } + + UIEditorWorkspaceNode* targetStack = + FindMutableNodeRecursive(workspace.root, targetNodeId); + if (targetStack == nullptr || + targetStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + const std::string movedPanelId = panelNode.panel.panelId; + const std::size_t actualInsertionIndex = + ResolveActualInsertionIndexForVisibleInsertion( + *targetStack, + session, + targetVisibleInsertionIndex); + if (actualInsertionIndex > targetStack->children.size()) { + return false; + } + + targetStack->children.insert( + targetStack->children.begin() + + static_cast(actualInsertionIndex), + std::move(panelNode)); + targetStack->selectedTabIndex = actualInsertionIndex; + workspace.activePanelId = movedPanelId; + workspace = CanonicalizeUIEditorWorkspaceModel(std::move(workspace)); + return true; +} + +bool TryDockUIEditorWorkspacePanelNodeRelative( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + UIEditorWorkspaceNode panelNode, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio) { + if (targetNodeId.empty() || + panelNode.kind != UIEditorWorkspaceNodeKind::Panel || + panelNode.panel.panelId.empty()) { + return false; + } + + const UIEditorWorkspaceNode* targetNode = + FindUIEditorWorkspaceNode(workspace, targetNodeId); + if (targetNode == nullptr || + targetNode->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + if (placement == UIEditorWorkspaceDockPlacement::Center) { + return TryInsertUIEditorWorkspacePanelNodeToStack( + workspace, + session, + std::move(panelNode), + targetNodeId, + CountVisibleChildren(*targetNode, session)); + } + + UIEditorWorkspaceNode* targetStack = + FindMutableNodeRecursive(workspace.root, targetNodeId); + if (targetStack == nullptr || + targetStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + const std::string movedPanelId = panelNode.panel.panelId; + const std::string movedStackNodeId = MakeUniqueNodeId( + workspace, + std::string(targetNodeId) + "__dock_" + movedPanelId + "_stack"); + UIEditorWorkspaceNode movedStack = {}; + movedStack.kind = UIEditorWorkspaceNodeKind::TabStack; + movedStack.nodeId = movedStackNodeId; + movedStack.selectedTabIndex = 0u; + movedStack.children.push_back(std::move(panelNode)); + + UIEditorWorkspaceNode existingTarget = std::move(*targetStack); + UIEditorWorkspaceNode primary = {}; + UIEditorWorkspaceNode secondary = {}; + if (IsLeadingDockPlacement(placement)) { + primary = std::move(movedStack); + secondary = std::move(existingTarget); + } else { + primary = std::move(existingTarget); + secondary = std::move(movedStack); + } + + const float requestedRatio = ClampDockSplitRatio(splitRatio); + const float resolvedSplitRatio = + IsLeadingDockPlacement(placement) + ? requestedRatio + : (1.0f - requestedRatio); + *targetStack = BuildUIEditorWorkspaceSplit( + MakeUniqueNodeId( + workspace, + std::string(targetNodeId) + "__dock_split"), + ResolveDockSplitAxis(placement), + resolvedSplitRatio, + std::move(primary), + std::move(secondary)); + workspace.activePanelId = movedPanelId; + workspace = CanonicalizeUIEditorWorkspaceModel(std::move(workspace)); + return true; +} + bool TryMoveUIEditorWorkspaceTabToStack( UIEditorWorkspaceModel& workspace, const UIEditorWorkspaceSession& session, @@ -882,30 +1021,12 @@ bool TryMoveUIEditorWorkspaceTabToStack( return false; } - UIEditorWorkspaceNode* targetStack = - FindMutableNodeRecursive(workspace.root, targetNodeId); - if (targetStack == nullptr || - targetStack->kind != UIEditorWorkspaceNodeKind::TabStack) { - return false; - } - - const std::size_t actualInsertionIndex = - ResolveActualInsertionIndexForVisibleInsertion( - *targetStack, - session, - targetVisibleInsertionIndex); - if (actualInsertionIndex > targetStack->children.size()) { - return false; - } - - targetStack->children.insert( - targetStack->children.begin() + - static_cast(actualInsertionIndex), - std::move(extractedPanel)); - targetStack->selectedTabIndex = actualInsertionIndex; - workspace.activePanelId = std::string(panelId); - workspace = CanonicalizeUIEditorWorkspaceModel(std::move(workspace)); - return true; + return TryInsertUIEditorWorkspacePanelNodeToStack( + workspace, + session, + std::move(extractedPanel), + targetNodeId, + targetVisibleInsertionIndex); } bool TryDockUIEditorWorkspaceTabRelative( @@ -965,49 +1086,13 @@ bool TryDockUIEditorWorkspaceTabRelative( return false; } - UIEditorWorkspaceNode* targetStack = - FindMutableNodeRecursive(workspace.root, targetNodeId); - if (targetStack == nullptr || - targetStack->kind != UIEditorWorkspaceNodeKind::TabStack) { - return false; - } - - const std::string movedStackNodeId = MakeUniqueNodeId( + return TryDockUIEditorWorkspacePanelNodeRelative( workspace, - std::string(targetNodeId) + "__dock_" + std::string(panelId) + "_stack"); - UIEditorWorkspaceNode movedStack = {}; - movedStack.kind = UIEditorWorkspaceNodeKind::TabStack; - movedStack.nodeId = movedStackNodeId; - movedStack.selectedTabIndex = 0u; - movedStack.children.push_back(std::move(extractedPanel)); - - UIEditorWorkspaceNode existingTarget = std::move(*targetStack); - UIEditorWorkspaceNode primary = {}; - UIEditorWorkspaceNode secondary = {}; - if (IsLeadingDockPlacement(placement)) { - primary = std::move(movedStack); - secondary = std::move(existingTarget); - } else { - primary = std::move(existingTarget); - secondary = std::move(movedStack); - } - - const float requestedRatio = ClampDockSplitRatio(splitRatio); - const float resolvedSplitRatio = - IsLeadingDockPlacement(placement) - ? requestedRatio - : (1.0f - requestedRatio); - *targetStack = BuildUIEditorWorkspaceSplit( - MakeUniqueNodeId( - workspace, - std::string(targetNodeId) + "__dock_split"), - ResolveDockSplitAxis(placement), - resolvedSplitRatio, - std::move(primary), - std::move(secondary)); - workspace.activePanelId = std::string(panelId); - workspace = CanonicalizeUIEditorWorkspaceModel(std::move(workspace)); - return true; + session, + std::move(extractedPanel), + targetNodeId, + placement, + splitRatio); } } // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Shell/UIEditorWorkspaceTransfer.cpp b/new_editor/src/Shell/UIEditorWorkspaceTransfer.cpp new file mode 100644 index 00000000..252f9610 --- /dev/null +++ b/new_editor/src/Shell/UIEditorWorkspaceTransfer.cpp @@ -0,0 +1,209 @@ +#include + +#include +#include +#include + +namespace XCEngine::UI::Editor { + +namespace { + +bool IsPanelOpenAndVisible( + const UIEditorWorkspaceSession& session, + std::string_view panelId) { + const UIEditorPanelSessionState* state = FindUIEditorPanelSessionState(session, panelId); + return state != nullptr && state->open && state->visible; +} + +void CollectWorkspacePanelIdsRecursive( + const UIEditorWorkspaceNode& node, + std::vector& outPanelIds) { + if (node.kind == UIEditorWorkspaceNodeKind::Panel) { + outPanelIds.push_back(node.panel.panelId); + return; + } + + for (const UIEditorWorkspaceNode& child : node.children) { + CollectWorkspacePanelIdsRecursive(child, outPanelIds); + } +} + +void ReorderSessionStatesToMatchWorkspace( + const UIEditorWorkspaceModel& workspace, + UIEditorWorkspaceSession& session) { + std::vector orderedPanelIds = {}; + CollectWorkspacePanelIdsRecursive(workspace.root, orderedPanelIds); + + std::vector orderedStates = {}; + orderedStates.reserve(orderedPanelIds.size()); + for (const std::string& panelId : orderedPanelIds) { + auto it = std::find_if( + session.panelStates.begin(), + session.panelStates.end(), + [&panelId](const UIEditorPanelSessionState& state) { + return state.panelId == panelId; + }); + if (it != session.panelStates.end()) { + orderedStates.push_back(*it); + } + } + + session.panelStates = std::move(orderedStates); +} + +void NormalizeActivePanelAfterMutation( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view preferredPanelId) { + if (!preferredPanelId.empty() && + IsPanelOpenAndVisible(session, preferredPanelId) && + ContainsUIEditorWorkspacePanel(workspace, preferredPanelId)) { + TryActivateUIEditorWorkspacePanel(workspace, preferredPanelId); + return; + } + + const std::vector visiblePanels = + CollectUIEditorWorkspaceVisiblePanels(workspace, session); + if (visiblePanels.empty()) { + workspace.activePanelId.clear(); + return; + } + + TryActivateUIEditorWorkspacePanel(workspace, visiblePanels.front().panelId); +} + +} // namespace + +bool TryExtractUIEditorWorkspaceVisiblePanel( + UIEditorWorkspaceModel& workspace, + UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + UIEditorWorkspaceExtractedPanel& extractedPanel) { + const UIEditorWorkspaceModel workspaceBefore = workspace; + const UIEditorWorkspaceSession sessionBefore = session; + + UIEditorWorkspaceNode panelNode = {}; + if (!TryExtractUIEditorWorkspaceVisiblePanelNode( + workspace, + session, + sourceNodeId, + panelId, + panelNode)) { + return false; + } + + auto sessionIt = std::find_if( + session.panelStates.begin(), + session.panelStates.end(), + [panelId](const UIEditorPanelSessionState& state) { + return state.panelId == panelId; + }); + if (sessionIt == session.panelStates.end()) { + workspace = workspaceBefore; + session = sessionBefore; + return false; + } + + extractedPanel.panelNode = std::move(panelNode); + extractedPanel.sessionState = *sessionIt; + session.panelStates.erase(sessionIt); + ReorderSessionStatesToMatchWorkspace(workspace, session); + NormalizeActivePanelAfterMutation(workspace, session, workspace.activePanelId); + return true; +} + +UIEditorWorkspaceModel BuildUIEditorDetachedWorkspaceFromExtractedPanel( + std::string rootNodeId, + UIEditorWorkspaceExtractedPanel extractedPanel) { + if (rootNodeId.empty()) { + rootNodeId = "detached-workspace-root"; + } + + const std::string panelId = extractedPanel.panelNode.panel.panelId; + + UIEditorWorkspaceModel workspace = {}; + workspace.root.kind = UIEditorWorkspaceNodeKind::TabStack; + workspace.root.nodeId = std::move(rootNodeId); + workspace.root.selectedTabIndex = 0u; + workspace.root.children.push_back(std::move(extractedPanel.panelNode)); + workspace.activePanelId = panelId; + return workspace; +} + +UIEditorWorkspaceSession BuildUIEditorDetachedWorkspaceSessionFromExtractedPanel( + UIEditorWorkspaceExtractedPanel extractedPanel) { + UIEditorWorkspaceSession session = {}; + session.panelStates.push_back(std::move(extractedPanel.sessionState)); + return session; +} + +bool TryInsertExtractedUIEditorWorkspacePanelToStack( + UIEditorWorkspaceModel& workspace, + UIEditorWorkspaceSession& session, + UIEditorWorkspaceExtractedPanel extractedPanel, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex) { + if (extractedPanel.panelNode.kind != UIEditorWorkspaceNodeKind::Panel || + extractedPanel.sessionState.panelId != extractedPanel.panelNode.panel.panelId || + extractedPanel.sessionState.panelId.empty() || + FindUIEditorPanelSessionState(session, extractedPanel.sessionState.panelId) != nullptr) { + return false; + } + + const UIEditorWorkspaceModel workspaceBefore = workspace; + const UIEditorWorkspaceSession sessionBefore = session; + session.panelStates.push_back(extractedPanel.sessionState); + + if (!TryInsertUIEditorWorkspacePanelNodeToStack( + workspace, + session, + std::move(extractedPanel.panelNode), + targetNodeId, + targetVisibleInsertionIndex)) { + workspace = workspaceBefore; + session = sessionBefore; + return false; + } + + ReorderSessionStatesToMatchWorkspace(workspace, session); + NormalizeActivePanelAfterMutation(workspace, session, extractedPanel.sessionState.panelId); + return true; +} + +bool TryDockExtractedUIEditorWorkspacePanelRelative( + UIEditorWorkspaceModel& workspace, + UIEditorWorkspaceSession& session, + UIEditorWorkspaceExtractedPanel extractedPanel, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio) { + if (extractedPanel.panelNode.kind != UIEditorWorkspaceNodeKind::Panel || + extractedPanel.sessionState.panelId != extractedPanel.panelNode.panel.panelId || + extractedPanel.sessionState.panelId.empty() || + FindUIEditorPanelSessionState(session, extractedPanel.sessionState.panelId) != nullptr) { + return false; + } + + const UIEditorWorkspaceModel workspaceBefore = workspace; + const UIEditorWorkspaceSession sessionBefore = session; + session.panelStates.push_back(extractedPanel.sessionState); + + if (!TryDockUIEditorWorkspacePanelNodeRelative( + workspace, + session, + std::move(extractedPanel.panelNode), + targetNodeId, + placement, + splitRatio)) { + workspace = workspaceBefore; + session = sessionBefore; + return false; + } + + ReorderSessionStatesToMatchWorkspace(workspace, session); + NormalizeActivePanelAfterMutation(workspace, session, extractedPanel.sessionState.panelId); + return true; +} + +} // namespace XCEngine::UI::Editor diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index a97f4293..0434d495 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -61,6 +61,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_workspace_layout_persistence.cpp test_ui_editor_workspace_model.cpp test_ui_editor_workspace_session.cpp + test_ui_editor_window_workspace_controller.cpp ) add_executable(editor_ui_tests ${EDITOR_UI_UNIT_TEST_SOURCES}) @@ -88,7 +89,6 @@ if(MSVC) COMPILE_PDB_OUTPUT_DIRECTORY_RELEASE "${CMAKE_CURRENT_BINARY_DIR}/compile-pdb/Release" COMPILE_PDB_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_CURRENT_BINARY_DIR}/compile-pdb/MinSizeRel" COMPILE_PDB_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_CURRENT_BINARY_DIR}/compile-pdb/RelWithDebInfo" - VS_GLOBAL_UseMultiToolTask "false" ) set_property(TARGET editor_ui_tests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") diff --git a/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp index b338d691..b1107a31 100644 --- a/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp @@ -352,6 +352,57 @@ TEST(UIEditorDockHostInteractionTest, ClickingTabActivatesTargetPanel) { EXPECT_EQ(documentStack->selectedPanelId, "doc-a"); } +TEST(UIEditorDockHostInteractionTest, ReleasingActiveTabDragOutsideDockHostRequestsDetach) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildThreeDocumentWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + ASSERT_NE(documentStack, nullptr); + const UIRect draggedTabRect = documentStack->tabStripLayout.tabHeaderRects[2]; + const UIPoint draggedTabCenter = RectCenter(draggedTabRect); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(draggedTabCenter.x, draggedTabCenter.y) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::Tab); + EXPECT_EQ(frame.result.hitTarget.panelId, "doc-c"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(draggedTabCenter.x, draggedTabCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(860.0f, draggedTabCenter.y) }); + EXPECT_EQ(state.activeTabDragPanelId, "doc-c"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(860.0f, draggedTabCenter.y) }); + EXPECT_TRUE(frame.result.detachRequested); + EXPECT_TRUE(frame.result.consumed); + EXPECT_EQ(frame.result.detachedNodeId, "document-tabs"); + EXPECT_EQ(frame.result.detachedPanelId, "doc-c"); + EXPECT_TRUE(state.activeTabDragNodeId.empty()); + EXPECT_TRUE(state.activeTabDragPanelId.empty()); + EXPECT_FALSE(state.dockHostState.dropPreview.visible); +} + TEST(UIEditorDockHostInteractionTest, FocusedTabStripHandlesKeyboardNavigationThroughTabStripInteraction) { auto controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); diff --git a/tests/UI/Editor/unit/test_ui_editor_window_workspace_controller.cpp b/tests/UI/Editor/unit/test_ui_editor_window_workspace_controller.cpp new file mode 100644 index 00000000..16251c5c --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_window_workspace_controller.cpp @@ -0,0 +1,173 @@ +#include + +#include + +namespace { + +using XCEngine::UI::Editor::BuildDefaultUIEditorWindowWorkspaceController; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSingleTabStack; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels; +using XCEngine::UI::Editor::ContainsUIEditorWorkspacePanel; +using XCEngine::UI::Editor::FindUIEditorPanelSessionState; +using XCEngine::UI::Editor::FindUIEditorWindowWorkspaceState; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorWindowWorkspaceController; +using XCEngine::UI::Editor::UIEditorWindowWorkspaceOperationStatus; +using XCEngine::UI::Editor::UIEditorWorkspaceDockPlacement; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "inspector", "Inspector", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.7f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspaceSingleTabStack( + "inspector-panel", + "inspector", + "Inspector", + true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +std::vector CollectVisiblePanelIds( + const UIEditorWorkspaceModel& workspace, + const XCEngine::UI::Editor::UIEditorWorkspaceSession& session) { + const auto panels = CollectUIEditorWorkspaceVisiblePanels(workspace, session); + std::vector ids = {}; + ids.reserve(panels.size()); + for (const auto& panel : panels) { + ids.push_back(panel.panelId); + } + return ids; +} + +} // namespace + +TEST(UIEditorWindowWorkspaceControllerTest, DetachPanelCreatesNewDetachedWindowAndMovesSessionState) { + UIEditorWindowWorkspaceController controller = + BuildDefaultUIEditorWindowWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + + const auto result = controller.DetachPanelToNewWindow( + "main-window", + "document-tabs", + "doc-b", + "doc-b-window"); + EXPECT_EQ(result.status, UIEditorWindowWorkspaceOperationStatus::Changed); + EXPECT_EQ(result.targetWindowId, "doc-b-window"); + EXPECT_EQ(result.activeWindowId, "doc-b-window"); + ASSERT_EQ(result.windowIds.size(), 2u); + + const auto* mainWindow = + FindUIEditorWindowWorkspaceState(controller.GetWindowSet(), "main-window"); + ASSERT_NE(mainWindow, nullptr); + EXPECT_FALSE(ContainsUIEditorWorkspacePanel(mainWindow->workspace, "doc-b")); + EXPECT_EQ(FindUIEditorPanelSessionState(mainWindow->session, "doc-b"), nullptr); + + const auto* detachedWindow = + FindUIEditorWindowWorkspaceState(controller.GetWindowSet(), "doc-b-window"); + ASSERT_NE(detachedWindow, nullptr); + ASSERT_TRUE(ContainsUIEditorWorkspacePanel(detachedWindow->workspace, "doc-b")); + ASSERT_NE(FindUIEditorPanelSessionState(detachedWindow->session, "doc-b"), nullptr); + EXPECT_EQ(detachedWindow->workspace.activePanelId, "doc-b"); + + const auto mainVisibleIds = + CollectVisiblePanelIds(mainWindow->workspace, mainWindow->session); + ASSERT_EQ(mainVisibleIds.size(), 2u); + EXPECT_EQ(mainVisibleIds[0], "doc-a"); + EXPECT_EQ(mainVisibleIds[1], "inspector"); +} + +TEST(UIEditorWindowWorkspaceControllerTest, MovingSinglePanelDetachedWindowBackToMainClosesSourceWindow) { + UIEditorWindowWorkspaceController controller = + BuildDefaultUIEditorWindowWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + ASSERT_EQ( + controller.DetachPanelToNewWindow( + "main-window", + "document-tabs", + "doc-b", + "doc-b-window").status, + UIEditorWindowWorkspaceOperationStatus::Changed); + + const auto result = controller.MovePanelToStack( + "doc-b-window", + "doc-b-window-root", + "doc-b", + "main-window", + "document-tabs", + 1u); + EXPECT_EQ(result.status, UIEditorWindowWorkspaceOperationStatus::Changed); + EXPECT_EQ(result.activeWindowId, "main-window"); + ASSERT_EQ(result.windowIds.size(), 1u); + EXPECT_EQ(result.windowIds[0], "main-window"); + + EXPECT_EQ( + FindUIEditorWindowWorkspaceState(controller.GetWindowSet(), "doc-b-window"), + nullptr); + + const auto* mainWindow = + FindUIEditorWindowWorkspaceState(controller.GetWindowSet(), "main-window"); + ASSERT_NE(mainWindow, nullptr); + ASSERT_TRUE(ContainsUIEditorWorkspacePanel(mainWindow->workspace, "doc-b")); + ASSERT_NE(FindUIEditorPanelSessionState(mainWindow->session, "doc-b"), nullptr); + EXPECT_EQ(mainWindow->workspace.activePanelId, "doc-b"); +} + +TEST(UIEditorWindowWorkspaceControllerTest, DockingDetachedPanelIntoMainWindowAlsoClosesSourceWindow) { + UIEditorWindowWorkspaceController controller = + BuildDefaultUIEditorWindowWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + ASSERT_EQ( + controller.DetachPanelToNewWindow( + "main-window", + "document-tabs", + "doc-b", + "doc-b-window").status, + UIEditorWindowWorkspaceOperationStatus::Changed); + + const auto result = controller.DockPanelRelative( + "doc-b-window", + "doc-b-window-root", + "doc-b", + "main-window", + "inspector-panel", + UIEditorWorkspaceDockPlacement::Left, + 0.4f); + EXPECT_EQ(result.status, UIEditorWindowWorkspaceOperationStatus::Changed); + EXPECT_EQ(result.activeWindowId, "main-window"); + ASSERT_EQ(result.windowIds.size(), 1u); + + const auto* mainWindow = + FindUIEditorWindowWorkspaceState(controller.GetWindowSet(), "main-window"); + ASSERT_NE(mainWindow, nullptr); + ASSERT_TRUE(ContainsUIEditorWorkspacePanel(mainWindow->workspace, "doc-b")); + ASSERT_NE(FindUIEditorPanelSessionState(mainWindow->session, "doc-b"), nullptr); + + const auto visibleIds = + CollectVisiblePanelIds(mainWindow->workspace, mainWindow->session); + ASSERT_EQ(visibleIds.size(), 3u); + EXPECT_EQ(visibleIds[0], "doc-a"); + EXPECT_EQ(visibleIds[1], "doc-b"); + EXPECT_EQ(visibleIds[2], "inspector"); +}