diff --git a/CMakeLists.txt b/CMakeLists.txt index a74ff00..961d78e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,8 @@ add_executable(ReclassX src/providerregistry.h src/pluginmanager.cpp src/pluginmanager.h + src/typeselectorpopup.h + src/typeselectorpopup.cpp ) target_include_directories(ReclassX PRIVATE src) @@ -136,7 +138,8 @@ if(BUILD_TESTING) add_executable(test_controller tests/test_controller.cpp src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp - src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp) + src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp + src/typeselectorpopup.cpp) target_include_directories(test_controller PRIVATE src) target_link_libraries(test_controller PRIVATE Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test @@ -145,7 +148,8 @@ if(BUILD_TESTING) add_executable(test_validation tests/test_validation.cpp src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp - src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp) + src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp + src/typeselectorpopup.cpp) target_include_directories(test_validation PRIVATE src) target_link_libraries(test_validation PRIVATE Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test @@ -160,20 +164,40 @@ if(BUILD_TESTING) add_executable(test_context_menu tests/test_context_menu.cpp src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp - src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp) + src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp + src/typeselectorpopup.cpp) target_include_directories(test_context_menu PRIVATE src) target_link_libraries(test_context_menu PRIVATE Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test QScintilla::QScintilla dbghelp psapi) add_test(NAME test_context_menu COMMAND test_context_menu) + add_executable(test_rendered_view tests/test_rendered_view.cpp + src/generator.cpp src/compose.cpp src/format.cpp) + target_include_directories(test_rendered_view PRIVATE src) + target_link_libraries(test_rendered_view PRIVATE + Qt6::Widgets Qt6::PrintSupport Qt6::Test + QScintilla::QScintilla) + add_test(NAME test_rendered_view COMMAND test_rendered_view) + add_executable(test_new_features tests/test_new_features.cpp src/generator.cpp src/compose.cpp src/format.cpp src/controller.cpp - src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp) + src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp + src/typeselectorpopup.cpp) target_include_directories(test_new_features PRIVATE src) target_link_libraries(test_new_features PRIVATE Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test QScintilla::QScintilla dbghelp psapi) add_test(NAME test_new_features COMMAND test_new_features) + + add_executable(test_type_selector tests/test_type_selector.cpp + src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp + src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp + src/typeselectorpopup.cpp) + target_include_directories(test_type_selector PRIVATE src) + target_link_libraries(test_type_selector PRIVATE + Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test + QScintilla::QScintilla dbghelp psapi) + add_test(NAME test_type_selector COMMAND test_type_selector) endif() add_subdirectory(plugins/ProcessMemory) diff --git a/screenshot.png b/screenshot.png index eb497ed..3e98e60 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/src/compose.cpp b/src/compose.cpp index d251346..5f59b7c 100644 --- a/src/compose.cpp +++ b/src/compose.cpp @@ -348,9 +348,17 @@ void composeNode(ComposeState& state, const NodeTree& tree, } } - // Show referenced struct children: at dereferenced address if non-NULL, - // otherwise at offset 0 as a struct template preview + // Determine if pointer target is actually readable uint64_t pBase = (ptrVal != 0) ? ptrToProviderAddr(tree, ptrVal) : 0; + bool ptrReadable = (ptrVal != 0) && prov.isReadable(pBase, 1); + + // For invalid/unreadable pointers: use NullProvider (shows zeros) + // and reset margin offsets (unsigned wrap cancels baseAddress) + static NullProvider s_nullProv; + const Provider& childProv = ptrReadable ? prov : static_cast(s_nullProv); + if (!ptrReadable) + pBase = (uint64_t)0 - tree.baseAddress; + qulonglong key = pBase ^ (node.refId * kGoldenRatio); if (!state.ptrVisiting.contains(key)) { state.ptrVisiting.insert(key); @@ -358,7 +366,7 @@ void composeNode(ComposeState& state, const NodeTree& tree, if (refIdx >= 0) { const Node& ref = tree.nodes[refIdx]; if (ref.kind == NodeKind::Struct || ref.kind == NodeKind::Array) - composeParent(state, tree, prov, refIdx, + composeParent(state, tree, childProv, refIdx, depth, pBase, ref.id, /*isArrayChild=*/true); } @@ -474,7 +482,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR } // Emit CommandRow as line 0 (combined: source + address + root class type + name) - const QString cmdRowText = QStringLiteral("source\u25BE \u00B7 0x0 \u00B7 struct\u25BE {"); + const QString cmdRowText = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x0 \u00B7 struct\u25BE {"); { LineMeta lm; lm.nodeIdx = -1; diff --git a/src/controller.cpp b/src/controller.cpp index a6307f6..9188eb7 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -1,4 +1,5 @@ #include "controller.h" +#include "typeselectorpopup.h" #include "providers/process_provider.h" #include "providerregistry.h" #include "processpicker.h" @@ -15,6 +16,7 @@ #include #include #include +#include #include #ifdef _WIN32 #include @@ -171,9 +173,8 @@ RcxEditor* RcxController::primaryEditor() const { return m_editors.isEmpty() ? nullptr : m_editors.first(); } -RcxEditor* RcxController::addSplitEditor(QSplitter* splitter) { - auto* editor = new RcxEditor(splitter); - splitter->addWidget(editor); +RcxEditor* RcxController::addSplitEditor(QWidget* parent) { + auto* editor = new RcxEditor(parent); m_editors.append(editor); connectEditor(editor); @@ -186,7 +187,7 @@ RcxEditor* RcxController::addSplitEditor(QSplitter* splitter) { void RcxController::removeSplitEditor(RcxEditor* editor) { m_editors.removeOne(editor); - editor->deleteLater(); + // Caller (MainWindow) owns the parent QTabWidget and handles widget destruction. } void RcxController::connectEditor(RcxEditor* editor) { @@ -203,6 +204,12 @@ void RcxController::connectEditor(RcxEditor* editor) { handleNodeClick(editor, line, nodeId, mods); }); + // Type selector popup + connect(editor, &RcxEditor::typeSelectorRequested, + this, [this, editor]() { + showTypeSelectorPopup(editor); + }); + // Inline editing signals connect(editor, &RcxEditor::inlineEditCommitted, this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text) { @@ -1054,16 +1061,12 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, editor->beginInlineEdit(EditTarget::Name, line); }); - menu.addAction(icon("symbol-structure.svg"), "Change &Type\tT", [editor, line]() { + menu.addAction("Change &Type\tT", [editor, line]() { editor->beginInlineEdit(EditTarget::Type, line); }); menu.addSeparator(); - menu.addAction(icon("add.svg"), "&Add Field Below\tInsert", [this, parentId]() { - insertNode(parentId, -1, NodeKind::Hex64, "newField"); - }); - if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) { menu.addAction(icon("diff-added.svg"), "Add &Child", [this, nodeId]() { insertNode(nodeId, 0, NodeKind::Hex64, "newField"); @@ -1157,14 +1160,6 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, } } - menu.addAction(icon("add.svg"), "Add Hex64 at Root", [this]() { - uint64_t target = m_viewRootId ? m_viewRootId : 0; - insertNode(target, -1, NodeKind::Hex64, "newField"); - }); - menu.addAction(icon("symbol-structure.svg"), "Add Struct at Root", [this]() { - insertNode(0, -1, NodeKind::Struct, "NewClass"); - setViewRootId(0); // show all so the new struct is visible - }); menu.addAction(icon("diff-added.svg"), "Append 128 bytes", [this]() { uint64_t target = m_viewRootId ? m_viewRootId : 0; m_suppressRefresh = true; @@ -1450,22 +1445,35 @@ void RcxController::updateCommandRow() { .arg(elide(src, 40), elide(addr, 24), elide(sym, 40)); } - // Build row 2: root class type + name + // Build row 2: root class type + name (uses current view root) QString row2; - for (int i = 0; i < m_doc->tree.nodes.size(); i++) { - const auto& n = m_doc->tree.nodes[i]; - if (n.parentId == 0 && n.kind == NodeKind::Struct) { + if (m_viewRootId != 0) { + int vi = m_doc->tree.indexOfId(m_viewRootId); + if (vi >= 0) { + const auto& n = m_doc->tree.nodes[vi]; QString keyword = n.resolvedClassKeyword(); QString className = n.structTypeName.isEmpty() ? n.name : n.structTypeName; row2 = QStringLiteral("%1\u25BE %2 {") - .arg(keyword, className); - break; + .arg(keyword, className.isEmpty() ? QStringLiteral("") : className); + } + } + if (row2.isEmpty()) { + // Fallback: find first root struct + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + const auto& n = m_doc->tree.nodes[i]; + if (n.parentId == 0 && n.kind == NodeKind::Struct) { + QString keyword = n.resolvedClassKeyword(); + QString className = n.structTypeName.isEmpty() ? n.name : n.structTypeName; + row2 = QStringLiteral("%1\u25BE %2 {") + .arg(keyword, className); + break; + } } } if (row2.isEmpty()) row2 = QStringLiteral("struct\u25BE {"); - QString combined = row + QStringLiteral(" \u00B7 ") + row2; + QString combined = QStringLiteral("[\u25B8] ") + row + QStringLiteral(" \u00B7 ") + row2; for (auto* ed : m_editors) { ed->setCommandRowText(combined); @@ -1473,6 +1481,63 @@ void RcxController::updateCommandRow() { emit selectionChanged(m_selIds.size()); } +void RcxController::showTypeSelectorPopup(RcxEditor* editor) { + // Collect all root-level struct types + QVector types; + for (const auto& n : m_doc->tree.nodes) { + if (n.parentId == 0 && n.kind == NodeKind::Struct) { + TypeEntry entry; + entry.id = n.id; + entry.displayName = n.structTypeName.isEmpty() ? n.name : n.structTypeName; + entry.classKeyword = n.resolvedClassKeyword(); + types.append(entry); + } + } + + // Get font with zoom + QSettings settings("ReclassX", "ReclassX"); + QString fontName = settings.value("font", "Consolas").toString(); + QFont font(fontName, 12); + font.setFixedPitch(true); + auto* sci = editor->scintilla(); + int zoom = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETZOOM); + font.setPointSize(font.pointSize() + zoom); + + // Position: bottom-left of the [▸] span on line 0 + long lineStart = sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 0); + int lineH = (int)sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT, 0); + int x = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION, + 0, lineStart); + int y = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION, + 0, lineStart); + QPoint pos = sci->viewport()->mapToGlobal(QPoint(x, y + lineH)); + + auto* popup = new TypeSelectorPopup(editor); + popup->setFont(font); + popup->setTypes(types, m_viewRootId); + + connect(popup, &TypeSelectorPopup::typeSelected, + this, [this](uint64_t structId) { + setViewRootId(structId); + }); + connect(popup, &TypeSelectorPopup::createNewTypeRequested, + this, [this]() { + // Create a new root struct with no name + Node n; + n.kind = NodeKind::Struct; + n.name = QString(); + n.parentId = 0; + n.offset = 0; + n.id = m_doc->tree.reserveId(); + m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n})); + setViewRootId(n.id); + }); + connect(popup, &TypeSelectorPopup::dismissed, + popup, &QObject::deleteLater); + + popup->popup(pos); +} + void RcxController::attachToProcess(uint32_t pid, const QString& processName) { #ifdef _WIN32 HANDLE hProc = OpenProcess( diff --git a/src/controller.h b/src/controller.h index 94010ec..ae9bfdd 100644 --- a/src/controller.h +++ b/src/controller.h @@ -9,8 +9,6 @@ #include #include -class QSplitter; - namespace rcx { class RcxController; @@ -80,7 +78,7 @@ public: ~RcxController() override; RcxEditor* primaryEditor() const; - RcxEditor* addSplitEditor(QSplitter* splitter); + RcxEditor* addSplitEditor(QWidget* parent = nullptr); void removeSplitEditor(RcxEditor* editor); QList editors() const { return m_editors; } @@ -146,6 +144,7 @@ private: void attachToProcess(uint32_t pid, const QString& processName); void switchToSavedSource(int idx); void pushSavedSourcesToEditors(); + void showTypeSelectorPopup(RcxEditor* editor); // ── Auto-refresh methods ── void setupAutoRefresh(); diff --git a/src/core.h b/src/core.h index a346d54..b471a6e 100644 --- a/src/core.h +++ b/src/core.h @@ -489,7 +489,7 @@ struct ColumnSpan { enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount, ArrayElementType, ArrayElementCount, PointerTarget, - RootClassType, RootClassName }; + RootClassType, RootClassName, TypeSelector }; // Column layout constants (shared with format.cpp span computation) inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line @@ -635,6 +635,16 @@ inline ColumnSpan commandRowRootNameSpan(const QString& lineText) { return {nameStart, nameEnd, true}; } +// ── CommandRow type-selector chevron span ── +// Detects "[▸]" at the start of the command row text + +inline ColumnSpan commandRowChevronSpan(const QString& lineText) { + if (lineText.size() < 3) return {}; + if (lineText[0] == '[' && lineText[1] == QChar(0x25B8) && lineText[2] == ']') + return {0, 3, true}; + return {}; +} + // ── Array element type/count spans (within type column of array headers) ── // Line format: " int32_t[10] name {" // arrayElemTypeSpan covers "int32_t", arrayElemCountSpan covers "10" diff --git a/src/editor.cpp b/src/editor.cpp index 1f4604d..f0e8442 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -650,6 +650,11 @@ void RcxEditor::applyCommandRowPills() { clearIndicatorLine(IND_HEX_DIM, line); clearIndicatorLine(IND_CLASS_NAME, line); + // Dim the [▾] type-selector chevron + ColumnSpan chevron = commandRowChevronSpan(t); + if (chevron.valid) + fillIndicatorCols(IND_HEX_DIM, line, chevron.start, chevron.end); + // Dim label text: source arrow/placeholder + its ▾ dropdown arrow ColumnSpan srcSpan = commandRowSrcSpan(t); if (srcSpan.valid) { @@ -838,10 +843,12 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t, // CommandRow: Source / BaseAddress / Root class (type+name) editing if (lm->lineKind == LineKind::CommandRow) { if (t != EditTarget::BaseAddress && t != EditTarget::Source - && t != EditTarget::RootClassType && t != EditTarget::RootClassName) return false; + && t != EditTarget::RootClassType && t != EditTarget::RootClassName + && t != EditTarget::TypeSelector) return false; QString lineText = getLineText(m_sci, line); ColumnSpan s; - if (t == EditTarget::Source) s = commandRowSrcSpan(lineText); + if (t == EditTarget::TypeSelector) s = commandRowChevronSpan(lineText); + else if (t == EditTarget::Source) s = commandRowSrcSpan(lineText); else if (t == EditTarget::BaseAddress) s = commandRowAddrSpan(lineText); else if (t == EditTarget::RootClassType) s = commandRowRootTypeSpan(lineText); else s = commandRowRootNameSpan(lineText); @@ -959,8 +966,10 @@ static bool hitTestTarget(QsciScintilla* sci, return s.valid && col >= s.start && col < s.end; }; - // CommandRow: interactive SRC/ADDR + root class (type+name) + // CommandRow: interactive chevron/SRC/ADDR + root class (type+name) if (lm.lineKind == LineKind::CommandRow) { + ColumnSpan chevron = commandRowChevronSpan(lineText); + if (inSpan(chevron)) { outTarget = EditTarget::TypeSelector; outLine = line; return true; } ColumnSpan ss = commandRowSrcSpan(lineText); if (inSpan(ss)) { outTarget = EditTarget::Source; outLine = line; return true; } ColumnSpan as = commandRowAddrSpan(lineText); @@ -1102,11 +1111,15 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { emit marginClicked(0, h.line, me->modifiers()); return true; } - // CommandRow: try ADDR edit or consume + // CommandRow: try chevron/ADDR edit or consume if (h.nodeId == kCommandRowId) { int tLine; EditTarget t; - if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, t)) - beginInlineEdit(t, tLine); + if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, t)) { + if (t == EditTarget::TypeSelector) + emit typeSelectorRequested(); + else + beginInlineEdit(t, tLine); + } return true; // consume all CommandRow clicks } if (h.nodeId != 0) { @@ -1369,6 +1382,7 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) { // ── Begin inline edit ── bool RcxEditor::beginInlineEdit(EditTarget target, int line) { + if (target == EditTarget::TypeSelector) return false; // handled by popup, not inline edit if (m_editState.active) return false; m_hoveredNodeId = 0; m_hoveredLine = -1; @@ -1937,6 +1951,7 @@ void RcxEditor::applyHoverCursor() { case EditTarget::ArrayElementType: case EditTarget::PointerTarget: case EditTarget::RootClassType: + case EditTarget::TypeSelector: desired = Qt::PointingHandCursor; break; default: diff --git a/src/editor.h b/src/editor.h index c3d8115..75f9757 100644 --- a/src/editor.h +++ b/src/editor.h @@ -61,6 +61,7 @@ signals: void inlineEditCommitted(int nodeIdx, int subLine, EditTarget target, const QString& text); void inlineEditCancelled(); + void typeSelectorRequested(); protected: bool eventFilter(QObject* obj, QEvent* event) override; diff --git a/src/generator.cpp b/src/generator.cpp index b83c38e..f65f9d5 100644 --- a/src/generator.cpp +++ b/src/generator.cpp @@ -93,51 +93,61 @@ struct GenContext { // Forward declarations static void emitStruct(GenContext& ctx, uint64_t structId); -// ── Emit a single field declaration ── +// ── Field line with offset comment (code + marker + comment) ── +// We use a \x01 marker to separate the code part from the offset comment. +// After all output is generated, alignComments() replaces markers with padding. + +static const QChar kCommentMarker = QChar(0x01); + +static QString offsetComment(int offset) { + return QString(kCommentMarker) + QStringLiteral("// 0x%1").arg(QString::number(offset, 16).toUpper()); +} static QString emitField(GenContext& ctx, const Node& node) { const NodeTree& tree = ctx.tree; QString name = sanitizeIdent(node.name.isEmpty() ? QStringLiteral("field_%1").arg(node.offset, 2, 16, QChar('0')) : node.name); + QString oc = offsetComment(node.offset); switch (node.kind) { case NodeKind::Vec2: - return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name); + return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name) + oc; case NodeKind::Vec3: - return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name); + return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name) + oc; case NodeKind::Vec4: - return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name); + return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name) + oc; case NodeKind::Mat4x4: - return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name); + return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name) + oc; case NodeKind::UTF8: - return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen); + return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen) + oc; case NodeKind::UTF16: - return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen); + return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc; case NodeKind::Padding: - return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::Padding), name).arg(qMax(1, node.arrayLen)); + return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::Padding), name).arg(qMax(1, node.arrayLen)) + oc; case NodeKind::Pointer32: { if (node.refId != 0) { int refIdx = tree.indexOfId(node.refId); if (refIdx >= 0) { QString target = ctx.structName(tree.nodes[refIdx]); - return QStringLiteral(" %1 %2; // -> %3*").arg(ctx.cType(NodeKind::Pointer32), name, target); + return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) + + offsetComment(node.offset).replace(QStringLiteral("//"), QStringLiteral("// -> %1*").arg(target)); } } - return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name); + return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) + oc; } case NodeKind::Pointer64: { if (node.refId != 0) { int refIdx = tree.indexOfId(node.refId); if (refIdx >= 0) { QString target = ctx.structName(tree.nodes[refIdx]); - return QStringLiteral(" %1* %2;").arg(target, name); + return QStringLiteral(" %1* %2;").arg(target, name) + oc; } } - return QStringLiteral(" void* %1;").arg(name); + return QStringLiteral(" void* %1;").arg(name) + oc; } default: - return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name); + return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name) + oc; } } @@ -155,10 +165,21 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) { return tree.nodes[a].offset < tree.nodes[b].offset; }); - int cursor = 0; + // Helper: emit a padding/hex run as a single collapsed byte array + auto emitPadRun = [&](int offset, int size) { + if (size <= 0) return; + ctx.output += QStringLiteral(" %1 %2[0x%3];%4\n") + .arg(ctx.cType(NodeKind::Padding)) + .arg(ctx.uniquePadName()) + .arg(QString::number(size, 16).toUpper()) + .arg(offsetComment(offset)); + }; - for (int ci : children) { - const Node& child = tree.nodes[ci]; + int cursor = 0; + int i = 0; + + while (i < children.size()) { + const Node& child = tree.nodes[children[i]]; int childSize; if (child.kind == NodeKind::Struct || child.kind == NodeKind::Array) childSize = tree.structSpan(child.id, &ctx.childMap); @@ -166,28 +187,40 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) { childSize = child.byteSize(); // Gap before this field - if (child.offset > cursor) { - int gap = child.offset - cursor; - ctx.output += QStringLiteral(" %1 %2[0x%3];\n") - .arg(ctx.cType(NodeKind::Padding)) - .arg(ctx.uniquePadName()) - .arg(QString::number(gap, 16).toUpper()); - } else if (child.offset < cursor) { - // Overlap + if (child.offset > cursor) + emitPadRun(cursor, child.offset - cursor); + else if (child.offset < cursor) ctx.output += QStringLiteral(" // WARNING: overlap at offset 0x%1 (previous field ends at 0x%2)\n") .arg(QString::number(child.offset, 16).toUpper()) .arg(QString::number(cursor, 16).toUpper()); + + // Collapse consecutive hex nodes into a single padding array + if (isHexNode(child.kind)) { + int runStart = child.offset; + int runEnd = child.offset + childSize; + int j = i + 1; + while (j < children.size()) { + const Node& next = tree.nodes[children[j]]; + if (!isHexNode(next.kind)) break; + int nextSize = next.byteSize(); + // Allow gaps within the run (they become part of the pad) + if (next.offset < runEnd) break; // overlap — stop merging + runEnd = next.offset + nextSize; + j++; + } + emitPadRun(runStart, runEnd - runStart); + cursor = runEnd; + i = j; + continue; } // Emit the field if (child.kind == NodeKind::Struct) { - // Ensure the nested struct type is emitted first emitStruct(ctx, child.id); QString typeName = ctx.structName(child); QString fieldName = sanitizeIdent(child.name); - ctx.output += QStringLiteral(" %1 %2;\n").arg(typeName, fieldName); + ctx.output += QStringLiteral(" %1 %2;%3\n").arg(typeName, fieldName, offsetComment(child.offset)); } else if (child.kind == NodeKind::Array) { - // Check if array has struct element children QVector arrayKids = ctx.childMap.value(child.id); bool hasStructChild = false; QString elemTypeName; @@ -203,11 +236,11 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) { QString fieldName = sanitizeIdent(child.name); if (hasStructChild && !elemTypeName.isEmpty()) { - ctx.output += QStringLiteral(" %1 %2[%3];\n") - .arg(elemTypeName, fieldName).arg(child.arrayLen); + ctx.output += QStringLiteral(" %1 %2[%3];%4\n") + .arg(elemTypeName, fieldName).arg(child.arrayLen).arg(offsetComment(child.offset)); } else { - ctx.output += QStringLiteral(" %1 %2[%3];\n") - .arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen); + ctx.output += QStringLiteral(" %1 %2[%3];%4\n") + .arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen).arg(offsetComment(child.offset)); } } else { ctx.output += emitField(ctx, child) + QStringLiteral("\n"); @@ -215,16 +248,12 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) { int childEnd = child.offset + childSize; if (childEnd > cursor) cursor = childEnd; + i++; } // Tail padding - if (cursor < structSize) { - int gap = structSize - cursor; - ctx.output += QStringLiteral(" %1 %2[0x%3];\n") - .arg(ctx.cType(NodeKind::Padding)) - .arg(ctx.uniquePadName()) - .arg(QString::number(gap, 16).toUpper()); - } + if (cursor < structSize) + emitPadRun(cursor, structSize - cursor); } // ── Emit a complete struct definition ── @@ -294,7 +323,6 @@ static void emitStruct(GenContext& ctx, uint64_t structId) { ctx.emittedTypeNames.insert(typeName); int structSize = ctx.tree.structSpan(structId, &ctx.childMap); - ctx.output += QStringLiteral("#pragma pack(push, 1)\n"); QString kw = node.resolvedClassKeyword(); if (kw == QStringLiteral("enum")) kw = QStringLiteral("struct"); // enum is cosmetic ctx.output += QStringLiteral("%1 %2 {\n").arg(kw, typeName); @@ -302,7 +330,6 @@ static void emitStruct(GenContext& ctx, uint64_t structId) { emitStructBody(ctx, structId); ctx.output += QStringLiteral("};\n"); - ctx.output += QStringLiteral("#pragma pack(pop)\n"); ctx.output += QStringLiteral("static_assert(sizeof(%1) == 0x%2, \"Size mismatch for %1\");\n\n") .arg(typeName) .arg(QString::number(structSize, 16).toUpper()); @@ -319,22 +346,39 @@ static QHash> buildChildMap(const NodeTree& tree) { return map; } -// ── Path breadcrumb for header comment ── +// ── Align offset comments ── +// Replaces kCommentMarker with spaces so all "// 0x..." comments align to +// the same column (the longest code portion + 1 space). -static QString nodePath(const NodeTree& tree, uint64_t nodeId) { - QStringList parts; - QSet seen; - uint64_t cur = nodeId; - while (cur != 0 && !seen.contains(cur)) { - seen.insert(cur); - int idx = tree.indexOfId(cur); - if (idx < 0) break; - const Node& n = tree.nodes[idx]; - parts << (n.name.isEmpty() ? QStringLiteral("") : n.name); - cur = n.parentId; +static QString alignComments(const QString& raw) { + QStringList lines = raw.split('\n'); + + // First pass: find the maximum code width (text before the marker) + int maxCode = 0; + for (const QString& line : lines) { + int pos = line.indexOf(kCommentMarker); + if (pos >= 0) + maxCode = qMax(maxCode, pos); } - std::reverse(parts.begin(), parts.end()); - return parts.join(QStringLiteral(" > ")); + + // Second pass: replace markers with padding + QString result; + result.reserve(raw.size() + lines.size() * 8); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) result += '\n'; + const QString& line = lines[i]; + int pos = line.indexOf(kCommentMarker); + if (pos >= 0) { + result += line.left(pos); + int pad = maxCode - pos + 1; + if (pad < 1) pad = 1; + result += QString(pad, ' '); + result += line.mid(pos + 1); // skip the marker char + } else { + result += line; + } + } + return result; } } // anonymous namespace @@ -350,30 +394,19 @@ QString renderCpp(const NodeTree& tree, uint64_t rootStructId, if (root.kind != NodeKind::Struct) return {}; GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases}; - int rootSize = tree.structSpan(rootStructId, &ctx.childMap); - QString typeName = ctx.structName(root); - ctx.output += QStringLiteral("// Generated by ReclassX\n"); - ctx.output += QStringLiteral("// Rendered from: %1 (id=0x%2, size=0x%3)\n\n") - .arg(nodePath(tree, rootStructId)) - .arg(QString::number(rootStructId, 16).toUpper()) - .arg(QString::number(rootSize, 16).toUpper()); - ctx.output += QStringLiteral("#pragma once\n"); - ctx.output += QStringLiteral("#include \n\n"); + ctx.output += QStringLiteral("#pragma once\n\n"); emitStruct(ctx, rootStructId); - return ctx.output; + return alignComments(ctx.output); } QString renderCppAll(const NodeTree& tree, const QHash* typeAliases) { GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases}; - ctx.output += QStringLiteral("// Generated by ReclassX\n"); - ctx.output += QStringLiteral("// Full SDK export\n\n"); - ctx.output += QStringLiteral("#pragma once\n"); - ctx.output += QStringLiteral("#include \n\n"); + ctx.output += QStringLiteral("#pragma once\n\n"); QVector roots = ctx.childMap.value(0); std::sort(roots.begin(), roots.end(), [&](int a, int b) { @@ -385,7 +418,7 @@ QString renderCppAll(const NodeTree& tree, emitStruct(ctx, tree.nodes[ri].id); } - return ctx.output; + return alignComments(ctx.output); } QString renderNull(const NodeTree&, uint64_t) { diff --git a/src/main.cpp b/src/main.cpp index 76f8835..0726ddf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,7 +10,8 @@ #include #include #include -#include +#include +#include #include #include #include @@ -38,6 +39,7 @@ #include #include #include +#include #ifdef _WIN32 #include @@ -113,6 +115,18 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { } #endif +class MenuBarStyle : public QProxyStyle { +public: + using QProxyStyle::QProxyStyle; + QSize sizeFromContents(ContentsType type, const QStyleOption* opt, + const QSize& sz, const QWidget* w) const override { + QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w); + if (type == CT_MenuBarItem) + s.setHeight(s.height() + qRound(s.height() * 0.5)); + return s; + } +}; + namespace rcx { class MainWindow : public QMainWindow { @@ -122,6 +136,7 @@ public: private slots: void newFile(); + void newDocument(); void selfTest(); void openFile(); void saveFile(); @@ -151,21 +166,26 @@ public: void project_close(QMdiSubWindow* sub = nullptr); private: - enum ViewMode { VM_Reclass, VM_Rendered }; + enum ViewMode { VM_Reclass, VM_Rendered, VM_Debug }; QMdiArea* m_mdiArea; QLabel* m_statusLabel; PluginManager m_pluginManager; + struct SplitPane { + QTabWidget* tabWidget = nullptr; + RcxEditor* editor = nullptr; + QsciScintilla* rendered = nullptr; + ViewMode viewMode = VM_Reclass; + uint64_t lastRenderedRootId = 0; + }; + struct TabState { - RcxDocument* doc; - RcxController* ctrl; - QSplitter* splitter; - QStackedWidget* stack = nullptr; - QPointer rendered; - ViewMode viewMode = VM_Reclass; - uint64_t lastRenderedRootId = 0; - int lastRenderedFirstLine = 0; + RcxDocument* doc; + RcxController* ctrl; + QSplitter* splitter; + QVector panes; + int activePaneIdx = 0; }; QMap m_tabs; @@ -183,11 +203,18 @@ private: void updateWindowTitle(); void setViewMode(ViewMode mode); - void updateRenderedView(TabState& tab); + void updateRenderedView(TabState& tab, SplitPane& pane); + void updateAllRenderedPanes(TabState& tab); void syncRenderMenuState(); uint64_t findRootStructForNode(const NodeTree& tree, uint64_t nodeId) const; void setupRenderedSci(QsciScintilla* sci); + SplitPane createSplitPane(TabState& tab); + void applyTabWidgetStyle(QTabWidget* tw); + SplitPane* findPaneByTabWidget(QTabWidget* tw); + SplitPane* findActiveSplitPane(); + RcxEditor* activePaneEditor(); + // Workspace dock QDockWidget* m_workspaceDock = nullptr; QTreeView* m_workspaceTree = nullptr; @@ -210,6 +237,15 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { createMenus(); createStatusBar(); + + // Larger click targets + subtle hover on menu bar + { + menuBar()->setStyle(new MenuBarStyle(menuBar()->style())); + QPalette mp = menuBar()->palette(); + mp.setColor(QPalette::Highlight, QColor(43, 43, 43)); + menuBar()->setPalette(mp); + } + // Load plugins m_pluginManager.LoadPlugins(); @@ -219,31 +255,30 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { syncRenderMenuState(); rebuildWorkspaceModel(); }); + + // Track which split pane has focus (for menu-driven view switching) + connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* now) { + if (!now) return; + auto* tab = activeTab(); + if (!tab) return; + for (int i = 0; i < tab->panes.size(); ++i) { + if (tab->panes[i].tabWidget && tab->panes[i].tabWidget->isAncestorOf(now)) { + tab->activePaneIdx = i; + return; + } + } + }); } QIcon MainWindow::makeIcon(const QString& svgPath) { - // Render SVG at 14x14 (2px smaller) - QSvgRenderer renderer(svgPath); - QPixmap svgPixmap(14, 14); - svgPixmap.fill(Qt::transparent); - QPainter svgPainter(&svgPixmap); - renderer.render(&svgPainter); - svgPainter.end(); - - // Center it in a 16x16 canvas - QPixmap pixmap(16, 16); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - painter.drawPixmap(1, 1, svgPixmap); // Offset by 1px on each side - painter.end(); - - return QIcon(pixmap); + return QIcon(svgPath); } void MainWindow::createMenus() { // File auto* file = menuBar()->addMenu("&File"); - file->addAction(makeIcon(":/vsicons/file.svg"), "&New", QKeySequence::New, this, &MainWindow::newFile); + file->addAction("&New", QKeySequence::New, this, &MainWindow::newDocument); + file->addAction("New &Tab", QKeySequence(Qt::CTRL | Qt::Key_T), this, &MainWindow::newFile); file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", QKeySequence::Open, this, &MainWindow::openFile); file->addSeparator(); file->addAction(makeIcon(":/vsicons/save.svg"), "&Save", QKeySequence::Save, this, &MainWindow::saveFile); @@ -260,7 +295,7 @@ void MainWindow::createMenus() { edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", QKeySequence::Undo, this, &MainWindow::undo); edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", QKeySequence::Redo, this, &MainWindow::redo); edit->addSeparator(); - edit->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "&Type Aliases...", this, &MainWindow::showTypeAliasesDialog); + edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog); // View auto* view = menuBar()->addMenu("&View"); @@ -311,30 +346,131 @@ void MainWindow::createStatusBar() { m_statusLabel = new QLabel("Ready"); statusBar()->addWidget(m_statusLabel, 1); statusBar()->setStyleSheet("QStatusBar { background: #252526; color: #858585; }"); + + QSettings settings("ReclassX", "ReclassX"); + QString fontName = settings.value("font", "Consolas").toString(); + QFont f(fontName, 12); + f.setFixedPitch(true); + statusBar()->setFont(f); +} + +void MainWindow::applyTabWidgetStyle(QTabWidget* tw) { + QSettings settings("ReclassX", "ReclassX"); + QString fontName = settings.value("font", "Consolas").toString(); + QFont tabFont(fontName, 12); + tabFont.setFixedPitch(true); + tw->tabBar()->setFont(tabFont); + tw->setStyleSheet(QStringLiteral( + "QTabWidget::pane { border: none; }" + "QTabBar::tab {" + " background: #1e1e1e;" + " color: #585858;" + " padding: 4px 12px;" + " border: none;" + " min-width: 60px;" + "}" + "QTabBar::tab:selected {" + " color: #d4d4d4;" + "}" + "QTabBar::tab:hover {" + " color: #d4d4d4;" + "}" + )); + tw->tabBar()->setExpanding(false); +} + +MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) { + SplitPane pane; + + pane.tabWidget = new QTabWidget; + pane.tabWidget->setTabPosition(QTabWidget::South); + applyTabWidgetStyle(pane.tabWidget); + + // Create editor via controller (parent = tabWidget for ownership) + pane.editor = tab.ctrl->addSplitEditor(pane.tabWidget); + pane.tabWidget->addTab(pane.editor, "Reclass"); // index 0 + + // Create per-pane rendered C++ view + pane.rendered = new QsciScintilla; + setupRenderedSci(pane.rendered); + pane.tabWidget->addTab(pane.rendered, "C/C++"); // index 1 + + // Debug placeholder + auto* debugPage = new QWidget; + debugPage->setStyleSheet("background: #1e1e1e;"); + pane.tabWidget->addTab(debugPage, "Debug"); // index 2 + + pane.tabWidget->setCurrentIndex(0); + pane.viewMode = VM_Reclass; + + // Add to splitter + tab.splitter->addWidget(pane.tabWidget); + + // Connect per-pane tab bar switching + QTabWidget* tw = pane.tabWidget; + connect(tw, &QTabWidget::currentChanged, this, [this, tw](int index) { + // Find which pane this QTabWidget belongs to + SplitPane* p = findPaneByTabWidget(tw); + if (!p) return; + + if (index == 2) p->viewMode = VM_Debug; + else if (index == 1) p->viewMode = VM_Rendered; + else p->viewMode = VM_Reclass; + + if (index == 1) { + // Find the TabState that owns this pane and update rendered view + for (auto& tab : m_tabs) { + for (auto& pane : tab.panes) { + if (&pane == p) { + updateRenderedView(tab, pane); + break; + } + } + } + } + syncRenderMenuState(); + }); + + return pane; +} + +MainWindow::SplitPane* MainWindow::findPaneByTabWidget(QTabWidget* tw) { + for (auto& tab : m_tabs) { + for (auto& pane : tab.panes) { + if (pane.tabWidget == tw) + return &pane; + } + } + return nullptr; +} + +MainWindow::SplitPane* MainWindow::findActiveSplitPane() { + auto* tab = activeTab(); + if (!tab || tab->panes.isEmpty()) return nullptr; + int idx = qBound(0, tab->activePaneIdx, tab->panes.size() - 1); + return &tab->panes[idx]; +} + +RcxEditor* MainWindow::activePaneEditor() { + auto* pane = findActiveSplitPane(); + return pane ? pane->editor : nullptr; } QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { - // QStackedWidget wraps [0] splitter (Reclass view) and [1] rendered QsciScintilla - auto* stack = new QStackedWidget; auto* splitter = new QSplitter(Qt::Horizontal); auto* ctrl = new RcxController(doc, splitter); - ctrl->addSplitEditor(splitter); - stack->addWidget(splitter); // index 0 = Reclass view - - auto* renderedSci = new QsciScintilla; - setupRenderedSci(renderedSci); - stack->addWidget(renderedSci); // index 1 = Rendered view - stack->setCurrentIndex(0); - - auto* sub = m_mdiArea->addSubWindow(stack); + auto* sub = m_mdiArea->addSubWindow(splitter); sub->setWindowTitle(doc->filePath.isEmpty() ? "Untitled" : QFileInfo(doc->filePath).fileName()); sub->setAttribute(Qt::WA_DeleteOnClose); sub->showMaximized(); - m_tabs[sub] = { doc, ctrl, splitter, stack, renderedSci, - VM_Reclass, 0, 0 }; + m_tabs[sub] = { doc, ctrl, splitter, {}, 0 }; + auto& tab = m_tabs[sub]; + + // Create the initial split pane + tab.panes.append(createSplitPane(tab)); connect(sub, &QObject::destroyed, this, [this, sub]() { auto it = m_tabs.find(sub); @@ -349,8 +485,8 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { this, [this, ctrl, sub](int nodeIdx) { if (nodeIdx >= 0 && nodeIdx < ctrl->document()->tree.nodes.size()) { auto& node = ctrl->document()->tree.nodes[nodeIdx]; - auto it = m_tabs.find(sub); - if (it != m_tabs.end() && it->viewMode == VM_Rendered) + auto* ap = findActiveSplitPane(); + if (ap && ap->viewMode == VM_Rendered) m_statusLabel->setText( QString("Rendered: %1 %2") .arg(kindToString(node.kind)) @@ -365,10 +501,10 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { } else { m_statusLabel->setText("Ready"); } - // Update rendered view on selection change + // Update all rendered panes on selection change auto it = m_tabs.find(sub); if (it != m_tabs.end()) - updateRenderedView(*it); + updateAllRenderedPanes(*it); }); connect(ctrl, &RcxController::selectionChanged, this, [this](int count) { @@ -378,14 +514,14 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { m_statusLabel->setText(QString("%1 nodes selected").arg(count)); }); - // Update rendered view and workspace on document changes and undo/redo + // Update rendered panes and workspace on document changes and undo/redo connect(doc, &RcxDocument::documentChanged, this, [this, sub]() { auto it = m_tabs.find(sub); if (it != m_tabs.end()) QTimer::singleShot(0, this, [this, sub]() { auto it2 = m_tabs.find(sub); - if (it2 != m_tabs.end()) updateRenderedView(*it2); + if (it2 != m_tabs.end()) updateAllRenderedPanes(*it2); rebuildWorkspaceModel(); }); }); @@ -395,7 +531,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { if (it != m_tabs.end()) QTimer::singleShot(0, this, [this, sub]() { auto it2 = m_tabs.find(sub); - if (it2 != m_tabs.end()) updateRenderedView(*it2); + if (it2 != m_tabs.end()) updateAllRenderedPanes(*it2); }); }); @@ -412,72 +548,106 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { return sub; } +// Build Ball + Material demo structs into a tree +static void buildBallDemo(NodeTree& tree) { + // Ball struct (128 bytes = 0x80) + Node ball; + ball.kind = NodeKind::Struct; + ball.name = "aBall"; + ball.structTypeName = "Ball"; + ball.parentId = 0; + ball.offset = 0; + int bi = tree.addNode(ball); + uint64_t ballId = tree.nodes[bi].id; + + { Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = ballId; n.offset = 8; tree.addNode(n); } + { Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 16; tree.addNode(n); } + { Node n; n.kind = NodeKind::Vec3; n.name = "velocity"; n.parentId = ballId; n.offset = 32; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "field_2C"; n.parentId = ballId; n.offset = 44; tree.addNode(n); } + { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 48; tree.addNode(n); } + { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 52; tree.addNode(n); } + { Node n; n.kind = NodeKind::Float; n.name = "radius"; n.parentId = ballId; n.offset = 56; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "field_3C"; n.parentId = ballId; n.offset = 60; tree.addNode(n); } + { Node n; n.kind = NodeKind::Float; n.name = "mass"; n.parentId = ballId; n.offset = 64; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_44"; n.parentId = ballId; n.offset = 68; tree.addNode(n); } + { Node n; n.kind = NodeKind::Bool; n.name = "bouncy"; n.parentId = ballId; n.offset = 76; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex8; n.name = "field_4D"; n.parentId = ballId; n.offset = 77; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex16; n.name = "field_4E"; n.parentId = ballId; n.offset = 78; tree.addNode(n); } + { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 80; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "field_54"; n.parentId = ballId; n.offset = 84; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_58"; n.parentId = ballId; n.offset = 88; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_60"; n.parentId = ballId; n.offset = 96; tree.addNode(n); } + + // Material struct (renamed from Physics, 40 bytes = 0x28) + Node mat; + mat.kind = NodeKind::Struct; + mat.name = "aMaterial"; + mat.structTypeName = "Material"; + mat.parentId = 0; + mat.offset = 0; + int mi = tree.addNode(mat); + uint64_t matId = tree.nodes[mi].id; + + { Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = matId; n.offset = 0; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = matId; n.offset = 8; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_10"; n.parentId = matId; n.offset = 16; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = matId; n.offset = 24; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_20"; n.parentId = matId; n.offset = 32; tree.addNode(n); } + + // Pointer to Material in Ball struct + { Node n; n.kind = NodeKind::Pointer64; n.name = "material"; n.parentId = ballId; n.offset = 104; n.refId = matId; n.collapsed = true; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_70"; n.parentId = ballId; n.offset = 112; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "field_78"; n.parentId = ballId; n.offset = 120; tree.addNode(n); } +} + void MainWindow::newFile() { project_new(); } -void MainWindow::selfTest() { - QString demoPath = QCoreApplication::applicationDirPath() + "/demo.rcx"; - if (QFile::exists(demoPath)) { - project_open(demoPath); - } else { - // Create default demo with a single Ball struct - auto* doc = new RcxDocument(this); - doc->tree.baseAddress = 0x00400000; - - Node ball; - ball.kind = NodeKind::Struct; - ball.name = "aBall"; - ball.structTypeName = "ball"; - ball.parentId = 0; - ball.offset = 0; - int bi = doc->tree.addNode(ball); - uint64_t ballId = doc->tree.nodes[bi].id; - - { Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = ballId; n.offset = 0; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = ballId; n.offset = 8; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 16; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Vec3; n.name = "velocity"; n.parentId = ballId; n.offset = 32; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "field_2C"; n.parentId = ballId; n.offset = 44; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 48; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 52; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "radius"; n.parentId = ballId; n.offset = 56; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "field_3C"; n.parentId = ballId; n.offset = 60; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "mass"; n.parentId = ballId; n.offset = 64; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_44"; n.parentId = ballId; n.offset = 68; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Bool; n.name = "bouncy"; n.parentId = ballId; n.offset = 76; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "field_4D"; n.parentId = ballId; n.offset = 77; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "field_4E"; n.parentId = ballId; n.offset = 78; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 80; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "field_54"; n.parentId = ballId; n.offset = 84; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_58"; n.parentId = ballId; n.offset = 88; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_60"; n.parentId = ballId; n.offset = 96; doc->tree.addNode(n); } - - // Physics struct (defined at root level) - Node phys; - phys.kind = NodeKind::Struct; - phys.name = "aPhysics"; - phys.structTypeName = "Physics"; - phys.parentId = 0; - phys.offset = 0; - int pi = doc->tree.addNode(phys); - uint64_t physId = doc->tree.nodes[pi].id; - - { Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = physId; n.offset = 0; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = physId; n.offset = 8; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_10"; n.parentId = physId; n.offset = 16; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = physId; n.offset = 24; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_20"; n.parentId = physId; n.offset = 32; doc->tree.addNode(n); } - - // Pointer to Physics in ball struct - { Node n; n.kind = NodeKind::Pointer64; n.name = "physics"; n.parentId = ballId; n.offset = 104; n.refId = physId; n.collapsed = true; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_70"; n.parentId = ballId; n.offset = 112; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_78"; n.parentId = ballId; n.offset = 120; doc->tree.addNode(n); } - - doc->save(demoPath); - doc->load(demoPath); - createTab(doc); +void MainWindow::newDocument() { + auto* tab = activeTab(); + if (!tab) { + project_new(); + return; } + auto* doc = tab->doc; + auto* ctrl = tab->ctrl; + + // Clear everything + doc->undoStack.clear(); + doc->tree = NodeTree(); + doc->tree.baseAddress = 0x00400000; + doc->filePath.clear(); + doc->typeAliases.clear(); + doc->modified = false; + + // Build Ball + Material structs + buildBallDemo(doc->tree); + + // Cross-platform writable buffer, zeroed (256 bytes covers Ball + spare) + QByteArray data(256, '\0'); + doc->provider = std::make_shared(data); + + // Focus on Ball struct + ctrl->setViewRootId(0); + for (const auto& n : doc->tree.nodes) { + if (n.parentId == 0 && n.kind == NodeKind::Struct) { + ctrl->setViewRootId(n.id); + break; + } + } + ctrl->clearSelection(); + emit doc->documentChanged(); + + auto* sub = m_mdiArea->activeSubWindow(); + if (sub) sub->setWindowTitle("Untitled"); + updateWindowTitle(); + rebuildWorkspaceModel(); +} + +void MainWindow::selfTest() { + project_new(); } void MainWindow::openFile() { @@ -506,7 +676,7 @@ void MainWindow::addNode() { if (!ctrl) return; uint64_t parentId = ctrl->viewRootId(); // default to current view root - auto* primary = ctrl->primaryEditor(); + auto* primary = activePaneEditor(); if (primary && primary->isEditing()) return; if (primary) { int ni = primary->currentNodeIndex(); @@ -524,7 +694,7 @@ void MainWindow::addNode() { void MainWindow::removeNode() { auto* ctrl = activeController(); if (!ctrl) return; - auto* primary = ctrl->primaryEditor(); + auto* primary = activePaneEditor(); if (!primary || primary->isEditing()) return; QSet indices = primary->selectedNodeIndices(); if (indices.size() > 1) { @@ -537,7 +707,7 @@ void MainWindow::removeNode() { void MainWindow::changeNodeType() { auto* ctrl = activeController(); if (!ctrl) return; - auto* primary = ctrl->primaryEditor(); + auto* primary = activePaneEditor(); if (!primary) return; primary->beginInlineEdit(EditTarget::Type); } @@ -545,7 +715,7 @@ void MainWindow::changeNodeType() { void MainWindow::renameNodeAction() { auto* ctrl = activeController(); if (!ctrl) return; - auto* primary = ctrl->primaryEditor(); + auto* primary = activePaneEditor(); if (!primary) return; primary->beginInlineEdit(EditTarget::Name); } @@ -553,7 +723,7 @@ void MainWindow::renameNodeAction() { void MainWindow::duplicateNodeAction() { auto* ctrl = activeController(); if (!ctrl) return; - auto* primary = ctrl->primaryEditor(); + auto* primary = activePaneEditor(); if (!primary || primary->isEditing()) return; int ni = primary->currentNodeIndex(); if (ni >= 0) ctrl->duplicateNode(ni); @@ -562,15 +732,16 @@ void MainWindow::duplicateNodeAction() { void MainWindow::splitView() { auto* tab = activeTab(); if (!tab) return; - tab->ctrl->addSplitEditor(tab->splitter); + tab->panes.append(createSplitPane(*tab)); } void MainWindow::unsplitView() { auto* tab = activeTab(); - if (!tab) return; - auto editors = tab->ctrl->editors(); - if (editors.size() > 1) - tab->ctrl->removeSplitEditor(editors.last()); + if (!tab || tab->panes.size() <= 1) return; + auto pane = tab->panes.takeLast(); + tab->ctrl->removeSplitEditor(pane.editor); + pane.tabWidget->deleteLater(); + tab->activePaneIdx = qBound(0, tab->activePaneIdx, tab->panes.size() - 1); } void MainWindow::undo() { @@ -598,20 +769,27 @@ void MainWindow::setEditorFont(const QString& fontName) { f.setFixedPitch(true); for (auto& state : m_tabs) { state.ctrl->setEditorFont(fontName); - // Also update the rendered view font - if (state.rendered) { - state.rendered->setFont(f); - if (auto* lex = state.rendered->lexer()) { - lex->setFont(f); - for (int i = 0; i <= 127; i++) - lex->setFont(f, i); + for (auto& pane : state.panes) { + // Update rendered view font + if (pane.rendered) { + pane.rendered->setFont(f); + if (auto* lex = pane.rendered->lexer()) { + lex->setFont(f); + for (int i = 0; i <= 127; i++) + lex->setFont(f, i); + } + pane.rendered->setMarginsFont(f); } - state.rendered->setMarginsFont(f); + // Update per-pane tab bar font + if (pane.tabWidget) + applyTabWidgetStyle(pane.tabWidget); } } // Sync workspace tree font if (m_workspaceTree) m_workspaceTree->setFont(f); + // Sync status bar font + statusBar()->setFont(f); } RcxController* MainWindow::activeController() const { @@ -650,14 +828,10 @@ void MainWindow::setupRenderedSci(QsciScintilla* sci) { f.setFixedPitch(true); sci->setFont(f); - sci->setReadOnly(true); + sci->setReadOnly(false); sci->setWrapMode(QsciScintilla::WrapNone); - sci->setCaretLineVisible(false); - sci->setPaper(QColor("#1e1e1e")); - sci->setColor(QColor("#d4d4d4")); sci->setTabWidth(4); sci->setIndentationsUseTabs(false); - sci->setCaretForegroundColor(QColor("#d4d4d4")); sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2); sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2); @@ -672,7 +846,8 @@ void MainWindow::setupRenderedSci(QsciScintilla* sci) { sci->setMarginWidth(1, 0); sci->setMarginWidth(2, 0); - // C++ lexer for syntax highlighting + // C++ lexer for syntax highlighting — must be set BEFORE colors below, + // because setLexer() resets caret line, selection, and paper colors. auto* lexer = new QsciLexerCPP(sci); lexer->setFont(f); lexer->setColor(QColor("#569cd6"), QsciLexerCPP::Keyword); @@ -693,28 +868,34 @@ void MainWindow::setupRenderedSci(QsciScintilla* sci) { } sci->setLexer(lexer); sci->setBraceMatching(QsciScintilla::NoBraceMatch); + + // Colors applied AFTER setLexer() — the lexer resets these on attach + sci->setPaper(QColor("#1e1e1e")); + sci->setColor(QColor("#d4d4d4")); + sci->setCaretForegroundColor(QColor("#d4d4d4")); + sci->setCaretLineVisible(true); + sci->setCaretLineBackgroundColor(QColor(43, 43, 43)); // Match Reclass M_HOVER + sci->setSelectionBackgroundColor(QColor("#264f78")); // Match Reclass edit selection + sci->setSelectionForegroundColor(QColor("#d4d4d4")); } // ── View mode / generator switching ── void MainWindow::setViewMode(ViewMode mode) { - auto* tab = activeTab(); - if (!tab) return; - tab->viewMode = mode; - if (tab->stack) { - tab->stack->setCurrentIndex(mode == VM_Rendered ? 1 : 0); - } - if (mode == VM_Rendered) { - updateRenderedView(*tab); - } + auto* pane = findActiveSplitPane(); + if (!pane) return; + pane->viewMode = mode; + int idx = (mode == VM_Rendered) ? 1 : (mode == VM_Debug) ? 2 : 0; + pane->tabWidget->setCurrentIndex(idx); + // The QTabWidget::currentChanged signal will handle updating the rendered view syncRenderMenuState(); } void MainWindow::syncRenderMenuState() { - auto* tab = activeTab(); - bool rendered = tab && tab->viewMode == VM_Rendered; - if (m_actViewRendered) m_actViewRendered->setEnabled(!rendered); - if (m_actViewReclass) m_actViewReclass->setEnabled(rendered); + auto* pane = findActiveSplitPane(); + ViewMode vm = pane ? pane->viewMode : VM_Reclass; + if (m_actViewRendered) m_actViewRendered->setEnabled(vm != VM_Rendered); + if (m_actViewReclass) m_actViewReclass->setEnabled(vm != VM_Reclass); } // ── Find the root-level struct ancestor for a node ── @@ -737,11 +918,11 @@ uint64_t MainWindow::findRootStructForNode(const NodeTree& tree, uint64_t nodeId return lastStruct; } -// ── Update the rendered view for a tab ── +// ── Update the rendered view for a single pane ── -void MainWindow::updateRenderedView(TabState& tab) { - if (tab.viewMode != VM_Rendered) return; - if (!tab.rendered) return; +void MainWindow::updateRenderedView(TabState& tab, SplitPane& pane) { + if (pane.viewMode != VM_Rendered) return; + if (!pane.rendered) return; // Determine which struct to render based on selection uint64_t rootId = 0; @@ -763,26 +944,31 @@ void MainWindow::updateRenderedView(TabState& tab) { // Scroll restoration: save if same root, reset if different int restoreLine = 0; - if (rootId != 0 && rootId == tab.lastRenderedRootId) { - restoreLine = (int)tab.rendered->SendScintilla( + if (rootId != 0 && rootId == pane.lastRenderedRootId) { + restoreLine = (int)pane.rendered->SendScintilla( QsciScintillaBase::SCI_GETFIRSTVISIBLELINE); } - tab.lastRenderedRootId = rootId; + pane.lastRenderedRootId = rootId; // Set text - tab.rendered->setReadOnly(false); - tab.rendered->setText(text); - tab.rendered->setReadOnly(true); + pane.rendered->setText(text); // Update margin width for line count - int lineCount = tab.rendered->lines(); + int lineCount = pane.rendered->lines(); QString marginStr = QString(QString::number(lineCount).size() + 2, '0'); - tab.rendered->setMarginWidth(0, marginStr); + pane.rendered->setMarginWidth(0, marginStr); // Restore scroll if (restoreLine > 0) { - tab.rendered->SendScintilla(QsciScintillaBase::SCI_SETFIRSTVISIBLELINE, - (unsigned long)restoreLine); + pane.rendered->SendScintilla(QsciScintillaBase::SCI_SETFIRSTVISIBLELINE, + (unsigned long)restoreLine); + } +} + +void MainWindow::updateAllRenderedPanes(TabState& tab) { + for (auto& pane : tab.panes) { + if (pane.viewMode == VM_Rendered) + updateRenderedView(tab, pane); } } @@ -871,23 +1057,13 @@ void MainWindow::showTypeAliasesDialog() { QMdiSubWindow* MainWindow::project_new() { auto* doc = new RcxDocument(this); - QByteArray data(16, '\0'); + // Cross-platform writable buffer, zeroed (256 bytes covers Ball struct + spare) + QByteArray data(256, '\0'); doc->loadData(data); doc->tree.baseAddress = 0x00400000; - Node root; - root.kind = NodeKind::Struct; - root.name = "Entity"; - root.structTypeName = "Entity"; - root.parentId = 0; - root.offset = 0; - int ri = doc->tree.addNode(root); - uint64_t rootId = doc->tree.nodes[ri].id; - - { Node n; n.kind = NodeKind::Int32; n.name = "health"; n.parentId = rootId; n.offset = 0; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "armor"; n.parentId = rootId; n.offset = 4; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = rootId; n.offset = 8; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "flags"; n.parentId = rootId; n.offset = 12; doc->tree.addNode(n); } + // Build Ball + Material demo structs + buildBallDemo(doc->tree); auto* sub = createTab(doc); rebuildWorkspaceModel(); diff --git a/src/typeselectorpopup.cpp b/src/typeselectorpopup.cpp new file mode 100644 index 0000000..bf83681 --- /dev/null +++ b/src/typeselectorpopup.cpp @@ -0,0 +1,350 @@ +#include "typeselectorpopup.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rcx { + +// ── Custom delegate: gutter checkmark + icon + text ── + +class TypeSelectorDelegate : public QStyledItemDelegate { +public: + explicit TypeSelectorDelegate(TypeSelectorPopup* popup, QObject* parent = nullptr) + : QStyledItemDelegate(parent), m_popup(popup) {} + + void setFont(const QFont& f) { m_font = f; } + void setCurrentTypes(const QVector* filtered, uint64_t currentId) { + m_filtered = filtered; + m_currentId = currentId; + } + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override { + painter->save(); + + // Background + if (option.state & QStyle::State_Selected) + painter->fillRect(option.rect, option.palette.highlight()); + else if (option.state & QStyle::State_MouseOver) + painter->fillRect(option.rect, QColor(43, 43, 43)); + + int x = option.rect.x(); + int y = option.rect.y(); + int h = option.rect.height(); + + // 18px gutter: side triangle if current + int row = index.row(); + if (m_filtered && row >= 0 && row < m_filtered->size() + && (*m_filtered)[row].id == m_currentId) { + painter->setPen(QColor("#4ec9b0")); + QFont checkFont = m_font; + painter->setFont(checkFont); + painter->drawText(QRect(x, y, 18, h), Qt::AlignCenter, + QString(QChar(0x25B8))); + } + x += 18; + + // Icon 16x16 + static QIcon structIcon(QStringLiteral(":/vsicons/symbol-structure.svg")); + structIcon.paint(painter, x, y + (h - 16) / 2, 16, 16); + x += 20; + + // Text + painter->setPen(option.state & QStyle::State_Selected + ? option.palette.color(QPalette::HighlightedText) + : option.palette.color(QPalette::Text)); + painter->setFont(m_font); + painter->drawText(QRect(x, y, option.rect.right() - x, h), + Qt::AlignVCenter | Qt::AlignLeft, + index.data().toString()); + + painter->restore(); + } + + QSize sizeHint(const QStyleOptionViewItem& /*option*/, + const QModelIndex& /*index*/) const override { + QFontMetrics fm(m_font); + return QSize(200, fm.height() + 8); + } + +private: + TypeSelectorPopup* m_popup = nullptr; + QFont m_font; + const QVector* m_filtered = nullptr; + uint64_t m_currentId = 0; +}; + +// ── TypeSelectorPopup ── + +TypeSelectorPopup::TypeSelectorPopup(QWidget* parent) + : QFrame(parent, Qt::Popup | Qt::FramelessWindowHint) +{ + setAttribute(Qt::WA_DeleteOnClose, false); + + // Dark palette (no CSS) + QPalette pal; + pal.setColor(QPalette::Window, QColor("#252526")); + pal.setColor(QPalette::WindowText, QColor("#d4d4d4")); + pal.setColor(QPalette::Base, QColor("#1e1e1e")); + pal.setColor(QPalette::AlternateBase, QColor("#2a2d2e")); + pal.setColor(QPalette::Text, QColor("#d4d4d4")); + pal.setColor(QPalette::Button, QColor("#333333")); + pal.setColor(QPalette::ButtonText, QColor("#d4d4d4")); + pal.setColor(QPalette::Highlight, QColor("#264f78")); + pal.setColor(QPalette::HighlightedText, QColor("#ffffff")); + setPalette(pal); + setAutoFillBackground(true); + + // Thin border + setFrameShape(QFrame::Box); + setLineWidth(1); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(4); + + // Row 1: title + Esc hint + { + auto* row = new QHBoxLayout; + row->setContentsMargins(0, 0, 0, 0); + m_titleLabel = new QLabel(QStringLiteral("View as type")); + m_titleLabel->setPalette(pal); + QFont bold = m_titleLabel->font(); + bold.setBold(true); + m_titleLabel->setFont(bold); + row->addWidget(m_titleLabel); + + row->addStretch(); + + m_escLabel = new QLabel(QStringLiteral("Esc")); + QPalette dimPal = pal; + dimPal.setColor(QPalette::WindowText, QColor("#858585")); + m_escLabel->setPalette(dimPal); + row->addWidget(m_escLabel); + + layout->addLayout(row); + } + + // Row 2: + Create new type button + { + m_createBtn = new QToolButton; + m_createBtn->setText(QStringLiteral("+ Create new type\u2026")); + m_createBtn->setIcon(QIcon(QStringLiteral(":/vsicons/add.svg"))); + m_createBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + m_createBtn->setAutoRaise(true); + m_createBtn->setCursor(Qt::PointingHandCursor); + m_createBtn->setPalette(pal); + connect(m_createBtn, &QToolButton::clicked, this, [this]() { + emit createNewTypeRequested(); + hide(); + }); + layout->addWidget(m_createBtn); + } + + // Separator + { + auto* sep = new QFrame; + sep->setFrameShape(QFrame::HLine); + sep->setFrameShadow(QFrame::Plain); + QPalette sepPal = pal; + sepPal.setColor(QPalette::WindowText, QColor("#3c3c3c")); + sep->setPalette(sepPal); + sep->setFixedHeight(1); + layout->addWidget(sep); + } + + // Row 3: Filter + { + m_filterEdit = new QLineEdit; + m_filterEdit->setPlaceholderText(QStringLiteral("Filter types\u2026")); + m_filterEdit->setClearButtonEnabled(true); + m_filterEdit->setPalette(pal); + m_filterEdit->installEventFilter(this); + connect(m_filterEdit, &QLineEdit::textChanged, + this, &TypeSelectorPopup::applyFilter); + layout->addWidget(m_filterEdit); + } + + // Row 4: List + { + m_model = new QStringListModel(this); + m_listView = new QListView; + m_listView->setModel(m_model); + m_listView->setPalette(pal); + m_listView->setFrameShape(QFrame::NoFrame); + m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_listView->setMouseTracking(true); + m_listView->viewport()->setAttribute(Qt::WA_Hover, true); + m_listView->installEventFilter(this); + + auto* delegate = new TypeSelectorDelegate(this, m_listView); + m_listView->setItemDelegate(delegate); + + layout->addWidget(m_listView, 1); + + connect(m_listView, &QListView::clicked, + this, [this](const QModelIndex& index) { + acceptIndex(index.row()); + }); + } +} + +void TypeSelectorPopup::setFont(const QFont& font) { + m_font = font; + + m_titleLabel->setFont([&]() { + QFont f = font; f.setBold(true); return f; + }()); + m_escLabel->setFont(font); + m_createBtn->setFont(font); + m_filterEdit->setFont(font); + m_listView->setFont(font); + + auto* delegate = static_cast(m_listView->itemDelegate()); + if (delegate) + delegate->setFont(font); +} + +void TypeSelectorPopup::setTypes(const QVector& types, uint64_t currentId) { + m_allTypes = types; + m_currentId = currentId; + m_filterEdit->clear(); + applyFilter(QString()); +} + +void TypeSelectorPopup::popup(const QPoint& globalPos) { + // Size: width based on longest entry, height based on count + QFontMetrics fm(m_font); + int maxTextW = fm.horizontalAdvance(QStringLiteral("View as type Esc")); + for (const auto& t : m_allTypes) { + QString text = t.classKeyword + QStringLiteral(" ") + t.displayName; + int w = 18 + 20 + fm.horizontalAdvance(text) + 16; // gutter + icon + text + pad + if (w > maxTextW) maxTextW = w; + } + int popupW = qBound(250, maxTextW + 24, 500); // +margins + int rowH = fm.height() + 8; + int headerH = rowH * 3 + 30; // title + button + filter + separators/margins + int listH = qBound(rowH * 3, rowH * (int)m_allTypes.size(), rowH * 12); + int popupH = headerH + listH; + + // Clamp to screen + QScreen* screen = QApplication::screenAt(globalPos); + if (screen) { + QRect avail = screen->availableGeometry(); + if (globalPos.y() + popupH > avail.bottom()) + popupH = avail.bottom() - globalPos.y(); + if (globalPos.x() + popupW > avail.right()) + popupW = avail.right() - globalPos.x(); + } + + setFixedSize(popupW, popupH); + move(globalPos); + show(); + raise(); + activateWindow(); + m_filterEdit->setFocus(); + + // Pre-select current type in list + for (int i = 0; i < m_filteredTypes.size(); i++) { + if (m_filteredTypes[i].id == m_currentId) { + m_listView->setCurrentIndex(m_model->index(i)); + break; + } + } +} + +void TypeSelectorPopup::applyFilter(const QString& text) { + m_filteredTypes.clear(); + QStringList displayStrings; + + for (const auto& t : m_allTypes) { + if (text.isEmpty() + || t.displayName.contains(text, Qt::CaseInsensitive) + || t.classKeyword.contains(text, Qt::CaseInsensitive)) { + m_filteredTypes.append(t); + displayStrings << (t.classKeyword + QStringLiteral(" ") + t.displayName); + } + } + + m_model->setStringList(displayStrings); + + // Update delegate data + auto* delegate = static_cast(m_listView->itemDelegate()); + if (delegate) + delegate->setCurrentTypes(&m_filteredTypes, m_currentId); + + // Select first match + if (!m_filteredTypes.isEmpty()) + m_listView->setCurrentIndex(m_model->index(0)); +} + +void TypeSelectorPopup::acceptCurrent() { + QModelIndex idx = m_listView->currentIndex(); + if (idx.isValid()) + acceptIndex(idx.row()); +} + +void TypeSelectorPopup::acceptIndex(int row) { + if (row < 0 || row >= m_filteredTypes.size()) return; + emit typeSelected(m_filteredTypes[row].id); + hide(); +} + +bool TypeSelectorPopup::eventFilter(QObject* obj, QEvent* event) { + if (event->type() == QEvent::KeyPress) { + auto* ke = static_cast(event); + + if (ke->key() == Qt::Key_Escape) { + hide(); + return true; + } + + if (obj == m_filterEdit) { + if (ke->key() == Qt::Key_Down) { + m_listView->setFocus(); + if (!m_listView->currentIndex().isValid() && m_model->rowCount() > 0) + m_listView->setCurrentIndex(m_model->index(0)); + return true; + } + if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { + acceptCurrent(); + return true; + } + } + + if (obj == m_listView) { + if (ke->key() == Qt::Key_Up) { + QModelIndex cur = m_listView->currentIndex(); + if (!cur.isValid() || cur.row() == 0) { + m_filterEdit->setFocus(); + return true; + } + } + if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { + acceptCurrent(); + return true; + } + } + } + + return QFrame::eventFilter(obj, event); +} + +void TypeSelectorPopup::hideEvent(QHideEvent* event) { + QFrame::hideEvent(event); + emit dismissed(); +} + +} // namespace rcx diff --git a/src/typeselectorpopup.h b/src/typeselectorpopup.h new file mode 100644 index 0000000..734d65a --- /dev/null +++ b/src/typeselectorpopup.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include +#include + +class QLineEdit; +class QListView; +class QStringListModel; +class QLabel; +class QToolButton; + +namespace rcx { + +struct TypeEntry { + uint64_t id = 0; + QString displayName; + QString classKeyword; // "struct", "class", or "enum" +}; + +class TypeSelectorPopup : public QFrame { + Q_OBJECT +public: + explicit TypeSelectorPopup(QWidget* parent = nullptr); + + void setFont(const QFont& font); + void setTypes(const QVector& types, uint64_t currentId); + void popup(const QPoint& globalPos); + +signals: + void typeSelected(uint64_t structId); + void createNewTypeRequested(); + void dismissed(); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + void hideEvent(QHideEvent* event) override; + +private: + QLabel* m_titleLabel = nullptr; + QLabel* m_escLabel = nullptr; + QToolButton* m_createBtn = nullptr; + QLineEdit* m_filterEdit = nullptr; + QListView* m_listView = nullptr; + QStringListModel* m_model = nullptr; + + QVector m_allTypes; + QVector m_filteredTypes; + uint64_t m_currentId = 0; + QFont m_font; + + void applyFilter(const QString& text); + void acceptCurrent(); + void acceptIndex(int row); +}; + +} // namespace rcx diff --git a/tests/test_generator.cpp b/tests/test_generator.cpp index 451d8e8..b933982 100644 --- a/tests/test_generator.cpp +++ b/tests/test_generator.cpp @@ -54,18 +54,16 @@ private slots: QString result = rcx::renderCpp(tree, rootId); // Header - QVERIFY(result.contains("Generated by ReclassX")); QVERIFY(result.contains("#pragma once")); - QVERIFY(result.contains("#include ")); + QVERIFY(!result.contains("#include ")); + QVERIFY(!result.contains("#pragma pack")); // Struct definition - QVERIFY(result.contains("#pragma pack(push, 1)")); QVERIFY(result.contains("struct Player {")); QVERIFY(result.contains("int32_t health;")); QVERIFY(result.contains("float speed;")); QVERIFY(result.contains("uint64_t id;")); QVERIFY(result.contains("};")); - QVERIFY(result.contains("#pragma pack(pop)")); // static_assert - struct is 16 bytes (0+4 + 4+4 + 8+8 = 16) QVERIFY(result.contains("static_assert(sizeof(Player) == 0x10")); @@ -485,7 +483,6 @@ private slots: QString result = rcx::renderCppAll(tree); - QVERIFY(result.contains("Full SDK export")); QVERIFY(result.contains("struct StructA {")); QVERIFY(result.contains("struct StructB {")); QVERIFY(result.contains("uint32_t valueA;")); diff --git a/tests/test_rendered_view.cpp b/tests/test_rendered_view.cpp new file mode 100644 index 0000000..3447c3d --- /dev/null +++ b/tests/test_rendered_view.cpp @@ -0,0 +1,361 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "core.h" +#include "generator.h" + +// Raw Scintilla message IDs not exposed by QsciScintillaBase wrapper +static constexpr int SCI_GETSELBACK = 2477; +static constexpr int SCI_GETSELFORE = 2476; + +// ── Helper: extract BGR long from QColor (Scintilla stores colors as 0x00BBGGRR) ── + +static long toBGR(const QColor& c) { + return (long)c.red() | ((long)c.green() << 8) | ((long)c.blue() << 16); +} + +// ── Replicates MainWindow::setupRenderedSci so the test stays in sync ── + +static void setupRenderedSci(QsciScintilla* sci) { + QFont f("Consolas", 12); + f.setFixedPitch(true); + + sci->setFont(f); + sci->setReadOnly(false); + sci->setWrapMode(QsciScintilla::WrapNone); + sci->setTabWidth(4); + sci->setIndentationsUseTabs(false); + sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2); + sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2); + + // Line number margin + sci->setMarginType(0, QsciScintilla::NumberMargin); + sci->setMarginWidth(0, "00000"); + sci->setMarginsBackgroundColor(QColor("#252526")); + sci->setMarginsForegroundColor(QColor("#858585")); + sci->setMarginsFont(f); + + sci->setMarginWidth(1, 0); + sci->setMarginWidth(2, 0); + + // Lexer FIRST — setLexer() resets caret/selection/paper colors + auto* lexer = new QsciLexerCPP(sci); + lexer->setFont(f); + lexer->setColor(QColor("#569cd6"), QsciLexerCPP::Keyword); + lexer->setColor(QColor("#569cd6"), QsciLexerCPP::KeywordSet2); + lexer->setColor(QColor("#b5cea8"), QsciLexerCPP::Number); + lexer->setColor(QColor("#ce9178"), QsciLexerCPP::DoubleQuotedString); + lexer->setColor(QColor("#ce9178"), QsciLexerCPP::SingleQuotedString); + lexer->setColor(QColor("#6a9955"), QsciLexerCPP::Comment); + lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentLine); + lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentDoc); + lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Default); + lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Identifier); + lexer->setColor(QColor("#c586c0"), QsciLexerCPP::PreProcessor); + lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Operator); + for (int i = 0; i <= 127; i++) { + lexer->setPaper(QColor("#1e1e1e"), i); + lexer->setFont(f, i); + } + sci->setLexer(lexer); + sci->setBraceMatching(QsciScintilla::NoBraceMatch); + + // Colors AFTER setLexer() — the lexer resets these on attach + sci->setPaper(QColor("#1e1e1e")); + sci->setColor(QColor("#d4d4d4")); + sci->setCaretForegroundColor(QColor("#d4d4d4")); + sci->setCaretLineVisible(true); + sci->setCaretLineBackgroundColor(QColor(43, 43, 43)); + sci->setSelectionBackgroundColor(QColor("#264f78")); + sci->setSelectionForegroundColor(QColor("#d4d4d4")); +} + +// ── Test tree helper ── + +static rcx::NodeTree makeTestTree() { + rcx::NodeTree tree; + rcx::Node root; + root.kind = rcx::NodeKind::Struct; + root.name = "TestStruct"; + root.structTypeName = "TestStruct"; + root.parentId = 0; + root.offset = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + rcx::Node f1; + f1.kind = rcx::NodeKind::Int32; + f1.name = "health"; + f1.parentId = rootId; + f1.offset = 0; + tree.addNode(f1); + + rcx::Node f2; + f2.kind = rcx::NodeKind::Float; + f2.name = "speed"; + f2.parentId = rootId; + f2.offset = 4; + tree.addNode(f2); + + return tree; +} + +// ── Test class ── + +class TestRenderedView : public QObject { + Q_OBJECT + +private slots: + + // ── Verify caret line background is NOT yellow after setup ── + + void testCaretLineBackgroundNotYellow() { + QsciScintilla sci; + setupRenderedSci(&sci); + sci.show(); + sci.setText("struct Foo {\n int x;\n};\n"); + QTest::qWait(50); + + long bgr = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK); + long expected = toBGR(QColor(43, 43, 43)); + + // Yellow would be 0x00FFFF or similar high-value — ours should be dark + long yellow = toBGR(QColor(255, 255, 0)); + QVERIFY2(bgr != yellow, + qPrintable(QString("Caret line is yellow (0x%1), expected dark (0x%2)") + .arg(bgr, 6, 16, QChar('0')) + .arg(expected, 6, 16, QChar('0')))); + QCOMPARE(bgr, expected); + } + + // ── Verify caret line is enabled ── + + void testCaretLineEnabled() { + QsciScintilla sci; + setupRenderedSci(&sci); + + long visible = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEVISIBLE); + QCOMPARE(visible, (long)1); + } + + // ── Verify editor background (paper) is dark ── + + void testPaperColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + // Query default style background via Scintilla + long bgr = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK, + (unsigned long)0 /*STYLE_DEFAULT*/); + long expected = toBGR(QColor("#1e1e1e")); + QCOMPARE(bgr, expected); + } + + // ── Verify caret (cursor) foreground color ── + + void testCaretForegroundColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + long bgr = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETFORE); + long expected = toBGR(QColor("#d4d4d4")); + QCOMPARE(bgr, expected); + } + + // ── Verify selection colors are set (no direct Scintilla getter, but we can + // verify they survive a round-trip through the SCI_SETSEL* messages by + // checking the element colour API introduced in Scintilla 5.x) ── + + void testSelectionColorsApplied() { + QsciScintilla sci; + setupRenderedSci(&sci); + sci.show(); + sci.setText("int x = 42;\n"); + QTest::qWait(50); + + // Select text and verify rendering doesn't crash + sci.SendScintilla(QsciScintillaBase::SCI_SETSEL, (unsigned long)0, (long)3); + QTest::qWait(50); + + // SCI_GETELEMENTCOLOUR (element 10 = SC_ELEMENT_SELECTION_BACK) returns + // the selection back colour on Scintilla >= 5.2. If not available, fall + // back to verifying the calls didn't throw and caret line is still correct. + constexpr int SCI_GETELEMENTCOLOUR = 2753; + constexpr int SC_ELEMENT_SELECTION_BACK = 10; + + long selBack = sci.SendScintilla(SCI_GETELEMENTCOLOUR, + (unsigned long)SC_ELEMENT_SELECTION_BACK); + if (selBack != 0) { + // Scintilla 5.x: colour stored as 0xAABBGGRR (with alpha in high byte) + long bgrMask = selBack & 0x00FFFFFF; + long expected = toBGR(QColor("#264f78")); + QCOMPARE(bgrMask, expected); + } else { + // Older Scintilla: just verify caret line is still correct as a proxy + long caretBg = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK); + long expected = toBGR(QColor(43, 43, 43)); + QCOMPARE(caretBg, expected); + } + } + + // ── Verify lexer keyword color is VS Code blue, not default ── + + void testKeywordColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + auto* lexer = qobject_cast(sci.lexer()); + QVERIFY(lexer != nullptr); + + QColor kw = lexer->color(QsciLexerCPP::Keyword); + QCOMPARE(kw, QColor("#569cd6")); + } + + // ── Verify comment color is VS Code green ── + + void testCommentColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + auto* lexer = qobject_cast(sci.lexer()); + QVERIFY(lexer != nullptr); + + QCOMPARE(lexer->color(QsciLexerCPP::Comment), QColor("#6a9955")); + QCOMPARE(lexer->color(QsciLexerCPP::CommentLine), QColor("#6a9955")); + } + + // ── Verify number color is VS Code light green ── + + void testNumberColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + auto* lexer = qobject_cast(sci.lexer()); + QVERIFY(lexer != nullptr); + + QCOMPARE(lexer->color(QsciLexerCPP::Number), QColor("#b5cea8")); + } + + // ── Verify string color is VS Code orange ── + + void testStringColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + auto* lexer = qobject_cast(sci.lexer()); + QVERIFY(lexer != nullptr); + + QCOMPARE(lexer->color(QsciLexerCPP::DoubleQuotedString), QColor("#ce9178")); + QCOMPARE(lexer->color(QsciLexerCPP::SingleQuotedString), QColor("#ce9178")); + } + + // ── Verify preprocessor color is VS Code purple ── + + void testPreprocessorColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + auto* lexer = qobject_cast(sci.lexer()); + QVERIFY(lexer != nullptr); + + QCOMPARE(lexer->color(QsciLexerCPP::PreProcessor), QColor("#c586c0")); + } + + // ── Verify default/identifier text color ── + + void testDefaultTextColor() { + QsciScintilla sci; + setupRenderedSci(&sci); + + auto* lexer = qobject_cast(sci.lexer()); + QVERIFY(lexer != nullptr); + + QCOMPARE(lexer->color(QsciLexerCPP::Default), QColor("#d4d4d4")); + QCOMPARE(lexer->color(QsciLexerCPP::Identifier), QColor("#d4d4d4")); + QCOMPARE(lexer->color(QsciLexerCPP::Operator), QColor("#d4d4d4")); + } + + // ── Verify all 128 lexer styles have dark paper ── + + void testAllStylesHaveDarkPaper() { + QsciScintilla sci; + setupRenderedSci(&sci); + + auto* lexer = qobject_cast(sci.lexer()); + QVERIFY(lexer != nullptr); + + QColor expected("#1e1e1e"); + for (int i = 0; i <= 127; i++) { + QColor paper = lexer->paper(i); + QVERIFY2(paper == expected, + qPrintable(QString("Style %1 paper is %2, expected %3") + .arg(i).arg(paper.name()).arg(expected.name()))); + } + } + + // ── Verify margin colors match dark theme ── + + void testMarginColors() { + QsciScintilla sci; + setupRenderedSci(&sci); + + // Query margin background via Scintilla (style 33 = STYLE_LINENUMBER) + long marginBg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK, + (unsigned long)33); + long expectedBg = toBGR(QColor("#252526")); + QCOMPARE(marginBg, expectedBg); + + long marginFg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETFORE, + (unsigned long)33); + long expectedFg = toBGR(QColor("#858585")); + QCOMPARE(marginFg, expectedFg); + } + + // ── End-to-end: generate C++ and load into rendered view ── + + void testGeneratedCodeInRenderedView() { + auto tree = makeTestTree(); + uint64_t rootId = tree.nodes[0].id; + QString code = rcx::renderCpp(tree, rootId); + + // Verify generated code has no pragma pack / cstdint + QVERIFY(!code.contains("#pragma pack")); + QVERIFY(!code.contains("#include ")); + QVERIFY(code.contains("#pragma once")); + QVERIFY(code.contains("struct TestStruct {")); + + // Load into rendered sci and verify colors survive + QsciScintilla sci; + setupRenderedSci(&sci); + sci.show(); + sci.setText(code); + QTest::qWait(100); + + // Caret line must still be dark after text load + long caretBg = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK); + long expected = toBGR(QColor(43, 43, 43)); + QCOMPARE(caretBg, expected); + + // Paper must still be dark + long paperBg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK, + (unsigned long)0); + QCOMPARE(paperBg, toBGR(QColor("#1e1e1e"))); + } + + // ── Verify brace matching is disabled ── + + void testBraceMatchDisabled() { + QsciScintilla sci; + setupRenderedSci(&sci); + + QCOMPARE(sci.braceMatching(), QsciScintilla::NoBraceMatch); + } +}; + +QTEST_MAIN(TestRenderedView) +#include "test_rendered_view.moc" diff --git a/tests/test_type_selector.cpp b/tests/test_type_selector.cpp new file mode 100644 index 0000000..d0a3338 --- /dev/null +++ b/tests/test_type_selector.cpp @@ -0,0 +1,223 @@ +#include +#include +#include +#include +#include +#include "controller.h" +#include "typeselectorpopup.h" +#include "core.h" + +using namespace rcx; + +static void buildTwoRootTree(NodeTree& tree) { + tree.baseAddress = 0x1000; + + Node a; + a.kind = NodeKind::Struct; + a.name = "Alpha"; + a.structTypeName = "Alpha"; + a.parentId = 0; + a.offset = 0; + int ai = tree.addNode(a); + uint64_t aId = tree.nodes[ai].id; + + { Node n; n.kind = NodeKind::Int32; n.name = "x"; n.parentId = aId; n.offset = 0; tree.addNode(n); } + { Node n; n.kind = NodeKind::Int32; n.name = "y"; n.parentId = aId; n.offset = 4; tree.addNode(n); } + + Node b; + b.kind = NodeKind::Struct; + b.name = "Bravo"; + b.structTypeName = "Bravo"; + b.parentId = 0; + b.offset = 0x100; + int bi = tree.addNode(b); + uint64_t bId = tree.nodes[bi].id; + + { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = bId; n.offset = 0; tree.addNode(n); } +} + +static QByteArray makeBuffer() { + return QByteArray(0x200, '\0'); +} + +class TestTypeSelector : public QObject { + Q_OBJECT + +private slots: + + // ── Chevron span detection ── + + void testChevronSpanDetected() { + QString text = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x1000 \u00B7 struct\u25BE Alpha {"); + ColumnSpan span = commandRowChevronSpan(text); + QVERIFY(span.valid); + QCOMPARE(span.start, 0); + QCOMPARE(span.end, 3); + } + + void testChevronSpanRejects() { + QVERIFY(!commandRowChevronSpan(QStringLiteral("Hi")).valid); + QVERIFY(!commandRowChevronSpan(QStringLiteral("\u25B8 source")).valid); + // Old down-triangle glyph must not match + QVERIFY(!commandRowChevronSpan(QStringLiteral("[\u25BE] source")).valid); + } + + // ── Existing spans unbroken by chevron prefix ── + + void testSpansWithPrefix() { + QString text = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x1000 \u00B7 struct\u25BE Alpha {"); + + ColumnSpan src = commandRowSrcSpan(text); + QVERIFY(src.valid); + QVERIFY(text.mid(src.start, src.end - src.start).contains("source")); + + ColumnSpan addr = commandRowAddrSpan(text); + QVERIFY(addr.valid); + QVERIFY(text.mid(addr.start, addr.end - addr.start).contains("0x1000")); + + ColumnSpan rootName = commandRowRootNameSpan(text); + QVERIFY(rootName.valid); + QCOMPARE(text.mid(rootName.start, rootName.end - rootName.start).trimmed(), QString("Alpha")); + } + + // ── Popup data model ── + + void testPopupListsRootStructs() { + NodeTree tree; + buildTwoRootTree(tree); + + QVector types; + for (const auto& n : tree.nodes) { + if (n.parentId == 0 && n.kind == NodeKind::Struct) { + types.append({n.id, n.structTypeName.isEmpty() ? n.name : n.structTypeName, + n.resolvedClassKeyword()}); + } + } + + QCOMPARE(types.size(), 2); + QCOMPARE(types[0].displayName, QString("Alpha")); + QCOMPARE(types[1].displayName, QString("Bravo")); + } + + // ── Popup signals ── + + void testPopupSignals() { + TypeSelectorPopup popup; + popup.setTypes({{1, "A", "struct"}, {2, "B", "struct"}}, 1); + + QSignalSpy typeSpy(&popup, &TypeSelectorPopup::typeSelected); + QSignalSpy createSpy(&popup, &TypeSelectorPopup::createNewTypeRequested); + + emit popup.typeSelected(2); + QCOMPARE(typeSpy.count(), 1); + QCOMPARE(typeSpy.at(0).at(0).toULongLong(), (uint64_t)2); + + emit popup.createNewTypeRequested(); + QCOMPARE(createSpy.count(), 1); + } + + // ── Full GUI integration ── + // Single test method to avoid QScintilla reinit issues. + + void testViewSwitchingAndCreateType() { + auto* doc = new RcxDocument(); + buildTwoRootTree(doc->tree); + doc->provider = std::make_unique(makeBuffer()); + + auto* splitter = new QSplitter(); + auto* ctrl = new RcxController(doc, nullptr); + auto* editor = ctrl->addSplitEditor(splitter); + + splitter->resize(800, 600); + splitter->show(); + QVERIFY(QTest::qWaitForWindowExposed(splitter)); + + // Initial refresh so compose populates meta + editor text + ctrl->refresh(); + QApplication::processEvents(); + + auto* sci = editor->scintilla(); + + // -- Command row starts with [U+25B8] -- + { + const LineMeta* meta = editor->metaForLine(0); + QVERIFY(meta); + QCOMPARE(meta->lineKind, LineKind::CommandRow); + + QString line0 = sci->text(0); + if (line0.endsWith('\n')) line0.chop(1); + QVERIFY2(line0.startsWith(QStringLiteral("[\u25B8]")), + qPrintable("Expected chevron prefix, got: " + line0.left(10))); + } + + // -- Find root IDs -- + uint64_t alphaId = 0, bravoId = 0; + for (const auto& n : doc->tree.nodes) { + if (n.parentId == 0 && n.kind == NodeKind::Struct) { + if (n.name == "Alpha") alphaId = n.id; + if (n.name == "Bravo") bravoId = n.id; + } + } + QVERIFY(alphaId != 0); + QVERIFY(bravoId != 0); + QCOMPARE(ctrl->viewRootId(), (uint64_t)0); + + // -- Switch to Bravo: command row + fields update -- + ctrl->setViewRootId(bravoId); + QApplication::processEvents(); + + QCOMPARE(ctrl->viewRootId(), bravoId); + QVERIFY2(sci->text(0).contains("Bravo"), + qPrintable("Expected 'Bravo' in command row, got: " + sci->text(0))); + QVERIFY2(sci->text().contains("speed"), + "View should show Bravo's 'speed' field"); + + // -- Switch to Alpha -- + ctrl->setViewRootId(alphaId); + QApplication::processEvents(); + + QCOMPARE(ctrl->viewRootId(), alphaId); + QVERIFY2(sci->text(0).contains("Alpha"), + qPrintable("Expected 'Alpha' in command row, got: " + sci->text(0))); + + // -- Create new type (no name) -- + int nodesBefore = doc->tree.nodes.size(); + + Node newNode; + newNode.kind = NodeKind::Struct; + newNode.name = QString(); + newNode.parentId = 0; + newNode.offset = 0; + newNode.id = doc->tree.reserveId(); + uint64_t newId = newNode.id; + + doc->undoStack.push(new RcxCommand(ctrl, cmd::Insert{newNode})); + ctrl->setViewRootId(newId); + QApplication::processEvents(); + + // Verify new struct + int idx = doc->tree.indexOfId(newId); + QVERIFY(idx >= 0); + QVERIFY(doc->tree.nodes[idx].name.isEmpty()); + QCOMPARE(doc->tree.nodes[idx].kind, NodeKind::Struct); + QCOMPARE(doc->tree.nodes[idx].parentId, (uint64_t)0); + QCOMPARE(ctrl->viewRootId(), newId); + + // Command row shows "" + QVERIFY2(sci->text(0).contains(""), + qPrintable("Expected '' in command row, got: " + sci->text(0))); + + // -- Undo removes the new struct -- + doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(doc->tree.nodes.size(), nodesBefore); + + // Cleanup + delete ctrl; + delete splitter; + delete doc; + } +}; + +QTEST_MAIN(TestTypeSelector) +#include "test_type_selector.moc"