500 lines
16 KiB
C++
500 lines
16 KiB
C++
#include <XCEditor/Shell/UIEditorWorkspaceLayoutPersistence.h>
|
|
|
|
#include <iomanip>
|
|
#include <limits>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace XCEngine::UI::Editor {
|
|
|
|
namespace {
|
|
|
|
constexpr std::string_view kLayoutHeader = "XCUI_EDITOR_WORKSPACE_LAYOUT";
|
|
constexpr int kLayoutVersion = 1;
|
|
|
|
struct LayoutLine {
|
|
std::size_t number = 0u;
|
|
std::string text = {};
|
|
};
|
|
|
|
UIEditorWorkspaceLayoutLoadResult MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode code,
|
|
std::string message) {
|
|
UIEditorWorkspaceLayoutLoadResult result = {};
|
|
result.code = code;
|
|
result.message = std::move(message);
|
|
return result;
|
|
}
|
|
|
|
bool HasTrailingTokens(std::istringstream& stream) {
|
|
stream >> std::ws;
|
|
return !stream.eof();
|
|
}
|
|
|
|
std::string MakeLinePrefix(const LayoutLine& line) {
|
|
return "Line " + std::to_string(line.number) + ": ";
|
|
}
|
|
|
|
bool ParseBinaryFlag(
|
|
std::istringstream& stream,
|
|
const LayoutLine& line,
|
|
int& outValue,
|
|
UIEditorWorkspaceLayoutLoadResult& outError,
|
|
UIEditorWorkspaceLayoutLoadCode code,
|
|
std::string_view fieldName) {
|
|
if (!(stream >> outValue) || (outValue != 0 && outValue != 1)) {
|
|
outError = MakeLoadError(
|
|
code,
|
|
MakeLinePrefix(line) + std::string(fieldName) + " must be encoded as 0 or 1.");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool ParseNodeLine(
|
|
const LayoutLine& line,
|
|
std::string& outTag,
|
|
std::istringstream& outStream) {
|
|
outStream = std::istringstream(line.text);
|
|
return static_cast<bool>(outStream >> outTag);
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseNodeRecursive(
|
|
const std::vector<LayoutLine>& lines,
|
|
std::size_t& index,
|
|
UIEditorWorkspaceNode& outNode);
|
|
|
|
std::string SerializeAxis(UIEditorWorkspaceSplitAxis axis) {
|
|
return axis == UIEditorWorkspaceSplitAxis::Vertical ? "vertical" : "horizontal";
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseAxis(
|
|
std::string_view text,
|
|
UIEditorWorkspaceSplitAxis& outAxis,
|
|
const LayoutLine& line) {
|
|
if (text == "horizontal") {
|
|
outAxis = UIEditorWorkspaceSplitAxis::Horizontal;
|
|
return {};
|
|
}
|
|
|
|
if (text == "vertical") {
|
|
outAxis = UIEditorWorkspaceSplitAxis::Vertical;
|
|
return {};
|
|
}
|
|
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
MakeLinePrefix(line) + "split axis must be \"horizontal\" or \"vertical\".");
|
|
}
|
|
|
|
void SerializeNodeRecursive(
|
|
const UIEditorWorkspaceNode& node,
|
|
std::ostringstream& stream) {
|
|
switch (node.kind) {
|
|
case UIEditorWorkspaceNodeKind::Panel:
|
|
stream << "node_panel "
|
|
<< std::quoted(node.nodeId) << ' '
|
|
<< std::quoted(node.panel.panelId) << ' '
|
|
<< std::quoted(node.panel.title) << ' '
|
|
<< (node.panel.placeholder ? 1 : 0) << '\n';
|
|
return;
|
|
|
|
case UIEditorWorkspaceNodeKind::TabStack:
|
|
stream << "node_tabstack "
|
|
<< std::quoted(node.nodeId) << ' '
|
|
<< node.selectedTabIndex << ' '
|
|
<< node.children.size() << '\n';
|
|
for (const UIEditorWorkspaceNode& child : node.children) {
|
|
SerializeNodeRecursive(child, stream);
|
|
}
|
|
return;
|
|
|
|
case UIEditorWorkspaceNodeKind::Split:
|
|
stream << "node_split "
|
|
<< std::quoted(node.nodeId) << ' '
|
|
<< std::quoted(SerializeAxis(node.splitAxis)) << ' '
|
|
<< std::setprecision(std::numeric_limits<float>::max_digits10)
|
|
<< node.splitRatio << '\n';
|
|
for (const UIEditorWorkspaceNode& child : node.children) {
|
|
SerializeNodeRecursive(child, stream);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
std::vector<LayoutLine> CollectNonEmptyLines(std::string_view serializedLayout) {
|
|
std::vector<LayoutLine> lines = {};
|
|
std::istringstream stream{ std::string(serializedLayout) };
|
|
std::string text = {};
|
|
std::size_t lineNumber = 0u;
|
|
while (std::getline(stream, text)) {
|
|
++lineNumber;
|
|
if (!text.empty() && text.back() == '\r') {
|
|
text.pop_back();
|
|
}
|
|
|
|
const std::size_t first = text.find_first_not_of(" \t");
|
|
if (first == std::string::npos) {
|
|
continue;
|
|
}
|
|
|
|
lines.push_back({ lineNumber, text });
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseHeader(const LayoutLine& line) {
|
|
std::istringstream stream(line.text);
|
|
std::string header = {};
|
|
int version = 0;
|
|
if (!(stream >> header >> version) || HasTrailingTokens(stream)) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidHeader,
|
|
MakeLinePrefix(line) + "invalid layout header.");
|
|
}
|
|
|
|
if (header != kLayoutHeader) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidHeader,
|
|
MakeLinePrefix(line) + "layout header magic is invalid.");
|
|
}
|
|
|
|
if (version != kLayoutVersion) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::UnsupportedVersion,
|
|
MakeLinePrefix(line) + "unsupported layout version " + std::to_string(version) + ".");
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseActiveRecord(
|
|
const LayoutLine& line,
|
|
std::string& outActivePanelId) {
|
|
std::istringstream stream(line.text);
|
|
std::string tag = {};
|
|
if (!(stream >> tag) || tag != "active" ||
|
|
!(stream >> std::quoted(outActivePanelId)) ||
|
|
HasTrailingTokens(stream)) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::MissingActiveRecord,
|
|
MakeLinePrefix(line) + "active record must be `active \"panel-id\"`.");
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParsePanelNode(
|
|
std::istringstream& stream,
|
|
const LayoutLine& line,
|
|
UIEditorWorkspaceNode& outNode) {
|
|
std::string nodeId = {};
|
|
std::string panelId = {};
|
|
std::string title = {};
|
|
int placeholderValue = 0;
|
|
if (!(stream >> std::quoted(nodeId) >> std::quoted(panelId) >> std::quoted(title))) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
MakeLinePrefix(line) + "panel node record is malformed.");
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult boolParse = {};
|
|
if (!ParseBinaryFlag(
|
|
stream,
|
|
line,
|
|
placeholderValue,
|
|
boolParse,
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
"placeholder")) {
|
|
return boolParse;
|
|
}
|
|
|
|
if (HasTrailingTokens(stream)) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
MakeLinePrefix(line) + "panel node record contains trailing tokens.");
|
|
}
|
|
|
|
outNode = BuildUIEditorWorkspacePanel(
|
|
std::move(nodeId),
|
|
std::move(panelId),
|
|
std::move(title),
|
|
placeholderValue != 0);
|
|
return {};
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseTabStackNode(
|
|
const std::vector<LayoutLine>& lines,
|
|
std::size_t& index,
|
|
std::istringstream& stream,
|
|
const LayoutLine& line,
|
|
UIEditorWorkspaceNode& outNode) {
|
|
std::string nodeId = {};
|
|
std::size_t selectedTabIndex = 0u;
|
|
std::size_t childCount = 0u;
|
|
if (!(stream >> std::quoted(nodeId) >> selectedTabIndex >> childCount) ||
|
|
HasTrailingTokens(stream)) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
MakeLinePrefix(line) + "tab stack node record is malformed.");
|
|
}
|
|
|
|
outNode = {};
|
|
outNode.kind = UIEditorWorkspaceNodeKind::TabStack;
|
|
outNode.nodeId = std::move(nodeId);
|
|
outNode.selectedTabIndex = selectedTabIndex;
|
|
outNode.children.reserve(childCount);
|
|
for (std::size_t childIndex = 0; childIndex < childCount; ++childIndex) {
|
|
UIEditorWorkspaceNode child = {};
|
|
UIEditorWorkspaceLayoutLoadResult childResult = ParseNodeRecursive(lines, index, child);
|
|
if (!childResult.IsValid()) {
|
|
return childResult;
|
|
}
|
|
outNode.children.push_back(std::move(child));
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseSplitNode(
|
|
const std::vector<LayoutLine>& lines,
|
|
std::size_t& index,
|
|
std::istringstream& stream,
|
|
const LayoutLine& line,
|
|
UIEditorWorkspaceNode& outNode) {
|
|
std::string nodeId = {};
|
|
std::string axisText = {};
|
|
float splitRatio = 0.0f;
|
|
if (!(stream >> std::quoted(nodeId) >> std::quoted(axisText) >> splitRatio) ||
|
|
HasTrailingTokens(stream)) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
MakeLinePrefix(line) + "split node record is malformed.");
|
|
}
|
|
|
|
UIEditorWorkspaceSplitAxis axis = UIEditorWorkspaceSplitAxis::Horizontal;
|
|
if (UIEditorWorkspaceLayoutLoadResult axisResult = ParseAxis(axisText, axis, line);
|
|
!axisResult.IsValid()) {
|
|
return axisResult;
|
|
}
|
|
|
|
UIEditorWorkspaceNode primary = {};
|
|
UIEditorWorkspaceNode secondary = {};
|
|
if (UIEditorWorkspaceLayoutLoadResult primaryResult = ParseNodeRecursive(lines, index, primary);
|
|
!primaryResult.IsValid()) {
|
|
return primaryResult;
|
|
}
|
|
|
|
if (UIEditorWorkspaceLayoutLoadResult secondaryResult = ParseNodeRecursive(lines, index, secondary);
|
|
!secondaryResult.IsValid()) {
|
|
return secondaryResult;
|
|
}
|
|
|
|
outNode = BuildUIEditorWorkspaceSplit(
|
|
std::move(nodeId),
|
|
axis,
|
|
splitRatio,
|
|
std::move(primary),
|
|
std::move(secondary));
|
|
return {};
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseNodeRecursive(
|
|
const std::vector<LayoutLine>& lines,
|
|
std::size_t& index,
|
|
UIEditorWorkspaceNode& outNode) {
|
|
if (index >= lines.size()) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::UnexpectedEndOfInput,
|
|
"Unexpected end of input while parsing workspace nodes.");
|
|
}
|
|
|
|
const LayoutLine& line = lines[index++];
|
|
std::istringstream stream = {};
|
|
std::string tag = {};
|
|
if (!ParseNodeLine(line, tag, stream)) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
MakeLinePrefix(line) + "workspace node record is empty.");
|
|
}
|
|
|
|
if (tag == "node_panel") {
|
|
return ParsePanelNode(stream, line, outNode);
|
|
}
|
|
|
|
if (tag == "node_tabstack") {
|
|
return ParseTabStackNode(lines, index, stream, line, outNode);
|
|
}
|
|
|
|
if (tag == "node_split") {
|
|
return ParseSplitNode(lines, index, stream, line, outNode);
|
|
}
|
|
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidNodeRecord,
|
|
MakeLinePrefix(line) + "unknown workspace node tag '" + tag + "'.");
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult ParseSessionRecord(
|
|
const LayoutLine& line,
|
|
UIEditorPanelSessionState& outState) {
|
|
std::istringstream stream(line.text);
|
|
std::string tag = {};
|
|
int openValue = 0;
|
|
int visibleValue = 0;
|
|
if (!(stream >> tag) || tag != "session" ||
|
|
!(stream >> std::quoted(outState.panelId))) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidSessionRecord,
|
|
MakeLinePrefix(line) + "session record must start with `session`.");
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult boolParse = {};
|
|
if (!ParseBinaryFlag(
|
|
stream,
|
|
line,
|
|
openValue,
|
|
boolParse,
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidSessionRecord,
|
|
"open")) {
|
|
return boolParse;
|
|
}
|
|
|
|
if (!ParseBinaryFlag(
|
|
stream,
|
|
line,
|
|
visibleValue,
|
|
boolParse,
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidSessionRecord,
|
|
"visible")) {
|
|
return boolParse;
|
|
}
|
|
|
|
if (HasTrailingTokens(stream)) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidSessionRecord,
|
|
MakeLinePrefix(line) + "session record contains trailing tokens.");
|
|
}
|
|
|
|
outState.open = openValue != 0;
|
|
outState.visible = visibleValue != 0;
|
|
return {};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
UIEditorWorkspaceLayoutSnapshot BuildUIEditorWorkspaceLayoutSnapshot(
|
|
const UIEditorWorkspaceModel& workspace,
|
|
const UIEditorWorkspaceSession& session) {
|
|
UIEditorWorkspaceLayoutSnapshot snapshot = {};
|
|
snapshot.workspace = CanonicalizeUIEditorWorkspaceModel(workspace);
|
|
snapshot.session = session;
|
|
return snapshot;
|
|
}
|
|
|
|
bool AreUIEditorWorkspaceLayoutSnapshotsEquivalent(
|
|
const UIEditorWorkspaceLayoutSnapshot& lhs,
|
|
const UIEditorWorkspaceLayoutSnapshot& rhs) {
|
|
return AreUIEditorWorkspaceModelsEquivalent(lhs.workspace, rhs.workspace) &&
|
|
AreUIEditorWorkspaceSessionsEquivalent(lhs.session, rhs.session);
|
|
}
|
|
|
|
std::string SerializeUIEditorWorkspaceLayoutSnapshot(
|
|
const UIEditorWorkspaceLayoutSnapshot& snapshot) {
|
|
std::ostringstream stream = {};
|
|
stream << kLayoutHeader << ' ' << kLayoutVersion << '\n';
|
|
stream << "active " << std::quoted(snapshot.workspace.activePanelId) << '\n';
|
|
SerializeNodeRecursive(snapshot.workspace.root, stream);
|
|
for (const UIEditorPanelSessionState& state : snapshot.session.panelStates) {
|
|
stream << "session "
|
|
<< std::quoted(state.panelId) << ' '
|
|
<< (state.open ? 1 : 0) << ' '
|
|
<< (state.visible ? 1 : 0) << '\n';
|
|
}
|
|
return stream.str();
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult DeserializeUIEditorWorkspaceLayoutSnapshot(
|
|
const UIEditorPanelRegistry& panelRegistry,
|
|
std::string_view serializedLayout) {
|
|
const UIEditorPanelRegistryValidationResult registryValidation =
|
|
ValidateUIEditorPanelRegistry(panelRegistry);
|
|
if (!registryValidation.IsValid()) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidPanelRegistry,
|
|
"Panel registry invalid: " + registryValidation.message);
|
|
}
|
|
|
|
const std::vector<LayoutLine> lines = CollectNonEmptyLines(serializedLayout);
|
|
if (lines.empty()) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::EmptyInput,
|
|
"Serialized layout input is empty.");
|
|
}
|
|
|
|
if (UIEditorWorkspaceLayoutLoadResult headerResult = ParseHeader(lines.front());
|
|
!headerResult.IsValid()) {
|
|
return headerResult;
|
|
}
|
|
|
|
if (lines.size() < 2u) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::MissingActiveRecord,
|
|
"Serialized layout is missing the active panel record.");
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutSnapshot snapshot = {};
|
|
if (UIEditorWorkspaceLayoutLoadResult activeResult =
|
|
ParseActiveRecord(lines[1], snapshot.workspace.activePanelId);
|
|
!activeResult.IsValid()) {
|
|
return activeResult;
|
|
}
|
|
|
|
std::size_t index = 2u;
|
|
if (UIEditorWorkspaceLayoutLoadResult rootResult =
|
|
ParseNodeRecursive(lines, index, snapshot.workspace.root);
|
|
!rootResult.IsValid()) {
|
|
return rootResult;
|
|
}
|
|
|
|
snapshot.workspace = CanonicalizeUIEditorWorkspaceModel(std::move(snapshot.workspace));
|
|
|
|
snapshot.session.panelStates.clear();
|
|
while (index < lines.size()) {
|
|
UIEditorPanelSessionState state = {};
|
|
if (UIEditorWorkspaceLayoutLoadResult stateResult =
|
|
ParseSessionRecord(lines[index], state);
|
|
!stateResult.IsValid()) {
|
|
return stateResult;
|
|
}
|
|
snapshot.session.panelStates.push_back(std::move(state));
|
|
++index;
|
|
}
|
|
|
|
if (UIEditorWorkspaceValidationResult workspaceValidation =
|
|
ValidateUIEditorWorkspace(snapshot.workspace);
|
|
!workspaceValidation.IsValid()) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidWorkspace,
|
|
workspaceValidation.message);
|
|
}
|
|
|
|
if (UIEditorWorkspaceSessionValidationResult sessionValidation =
|
|
ValidateUIEditorWorkspaceSession(panelRegistry, snapshot.workspace, snapshot.session);
|
|
!sessionValidation.IsValid()) {
|
|
return MakeLoadError(
|
|
UIEditorWorkspaceLayoutLoadCode::InvalidWorkspaceSession,
|
|
sessionValidation.message);
|
|
}
|
|
|
|
UIEditorWorkspaceLayoutLoadResult result = {};
|
|
result.snapshot = std::move(snapshot);
|
|
return result;
|
|
}
|
|
|
|
} // namespace XCEngine::UI::Editor
|