Lay groundwork for detached editor windows

This commit is contained in:
2026-04-14 15:07:52 +08:00
parent 804e5138d7
commit 3f871a4f45
21 changed files with 1820 additions and 97 deletions

View File

@@ -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<UIEditorWindowWorkspaceState> 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 功能

View File

@@ -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

View File

@@ -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(

View File

@@ -17,6 +17,7 @@
#include "Workspace/ProductEditorWorkspace.h"
#include <XCEditor/Shell/UIEditorShellInteraction.h>
#include <XCEditor/Shell/UIEditorWorkspaceController.h>
#include <windows.h>
#include <windowsx.h>
@@ -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;

View File

@@ -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<void()> 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) {

View File

@@ -20,7 +20,7 @@ public:
bool Initialize(const std::filesystem::path& repoRoot);
void AttachTextMeasurer(const UIEditorTextMeasurer& textMeasurer);
void SetExitRequestHandler(std::function<void()> 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 = {};

View File

@@ -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<UIInputEvent> 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,

View File

@@ -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);

View File

@@ -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 = {};
};

View File

@@ -0,0 +1,91 @@
#pragma once
#include <XCEditor/Shell/UIEditorWindowWorkspaceModel.h>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
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<std::string> 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

View File

@@ -0,0 +1,62 @@
#pragma once
#include <XCEditor/Shell/UIEditorPanelRegistry.h>
#include <XCEditor/Shell/UIEditorWorkspaceSession.h>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine::UI::Editor {
struct UIEditorWindowWorkspaceState {
std::string windowId = {};
UIEditorWorkspaceModel workspace = {};
UIEditorWorkspaceSession session = {};
};
struct UIEditorWindowWorkspaceSet {
std::string primaryWindowId = {};
std::string activeWindowId = {};
std::vector<UIEditorWindowWorkspaceState> 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

View File

@@ -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,

View File

@@ -0,0 +1,41 @@
#pragma once
#include <XCEditor/Shell/UIEditorWorkspaceSession.h>
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

View File

@@ -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;

View File

@@ -0,0 +1,446 @@
#include <XCEditor/Shell/UIEditorWindowWorkspaceController.h>
#include <XCEditor/Shell/UIEditorWorkspaceTransfer.h>
#include <algorithm>
#include <utility>
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

View File

@@ -0,0 +1,145 @@
#include <XCEditor/Shell/UIEditorWindowWorkspaceModel.h>
#include <unordered_set>
#include <utility>
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<std::string> 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

View File

@@ -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<std::ptrdiff_t>(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<std::ptrdiff_t>(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

View File

@@ -0,0 +1,209 @@
#include <XCEditor/Shell/UIEditorWorkspaceTransfer.h>
#include <algorithm>
#include <utility>
#include <vector>
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<std::string>& 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<std::string> orderedPanelIds = {};
CollectWorkspacePanelIdsRecursive(workspace.root, orderedPanelIds);
std::vector<UIEditorPanelSessionState> 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<UIEditorWorkspaceVisiblePanel> 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

View File

@@ -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$<$<CONFIG:Debug>:Debug>DLL")

View File

@@ -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());

View File

@@ -0,0 +1,173 @@
#include <gtest/gtest.h>
#include <XCEditor/Shell/UIEditorWindowWorkspaceController.h>
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<std::string> CollectVisiblePanelIds(
const UIEditorWorkspaceModel& workspace,
const XCEngine::UI::Editor::UIEditorWorkspaceSession& session) {
const auto panels = CollectUIEditorWorkspaceVisiblePanels(workspace, session);
std::vector<std::string> 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");
}