diff --git a/src/compose.cpp b/src/compose.cpp index c648faa..6fbf093 100644 --- a/src/compose.cpp +++ b/src/compose.cpp @@ -39,7 +39,7 @@ struct ComposeState { if (currentLine > 0) text += '\n'; // 3-char fold indicator column: " - " expanded, " + " collapsed, " " other // CommandRow has no fold prefix (flush left) - if (lm.lineKind == LineKind::CommandRow) { + if (lm.lineKind == LineKind::CommandRow || lm.lineKind == LineKind::CommandRow2) { // no prefix } else if (lm.foldHead) text += lm.foldCollapsed ? QStringLiteral(" + ") : QStringLiteral(" - "); @@ -148,6 +148,16 @@ void composeLeaf(ComposeState& state, const NodeTree& tree, lm.effectiveNameW = nameW; lm.pointerTargetName = ptrTargetName; + // Set byte count for hex preview lines (used for per-byte change highlighting) + if (isHexPreview(node.kind)) { + if (node.kind == NodeKind::Padding) { + int totalSz = qMax(1, node.arrayLen); + lm.lineByteCount = qMin(8, totalSz - sub * 8); + } else { + lm.lineByteCount = sizeForKind(node.kind); + } + } + QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub, /*comment=*/{}, typeW, nameW, ptrTypeOverride); state.emitLine(lineText, lm); @@ -203,8 +213,14 @@ void composeParent(ComposeState& state, const NodeTree& tree, state.emitLine(fmt::indent(depth) + QStringLiteral("[%1]").arg(arrayElementIdx), lm); } - // Header line (skip for array element structs - condensed display) - if (!isArrayChild) { + // Detect root header: first root-level struct — suppressed from display + // (CommandRow2 already shows the root class type + name) + bool isRootHeader = (node.parentId == 0 && node.kind == NodeKind::Struct && !state.baseEmitted); + if (isRootHeader) + state.baseEmitted = true; + + // Header line (skip for array element structs and root struct) + if (!isArrayChild && !isRootHeader) { // Get per-scope widths for this header's parent scope int typeW = state.effectiveTypeW(scopeId); int nameW = state.effectiveNameW(scopeId); @@ -216,12 +232,11 @@ void composeParent(ComposeState& state, const NodeTree& tree, lm.lineKind = LineKind::Header; lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false); lm.nodeKind = node.kind; + lm.isRootHeader = false; lm.foldHead = true; lm.foldCollapsed = node.collapsed; lm.foldLevel = computeFoldLevel(depth, true); lm.markerMask = (1u << M_STRUCT_BG); - lm.isRootHeader = (node.parentId == 0 && node.kind == NodeKind::Struct && !state.baseEmitted); - if (lm.isRootHeader) state.baseEmitted = true; lm.effectiveTypeW = typeW; lm.effectiveNameW = nameW; @@ -240,26 +255,29 @@ void composeParent(ComposeState& state, const NodeTree& tree, state.emitLine(headerText, lm); } - if (!node.collapsed || isArrayChild) { + if (!node.collapsed || isArrayChild || isRootHeader) { QVector children = state.childMap.value(node.id); std::sort(children.begin(), children.end(), [&](int a, int b) { return tree.nodes[a].offset < tree.nodes[b].offset; }); + // Root struct children compose at same depth (no header to indent from) + int childDepth = isRootHeader ? depth : depth + 1; + // For arrays, render children as condensed (no header/footer for struct elements) bool childrenAreArrayElements = (node.kind == NodeKind::Array); int elementIdx = 0; for (int childIdx : children) { // Pass this container's id as the scope for children (for per-scope widths) // For array elements, also pass the element index for [N] separator - composeNode(state, tree, prov, childIdx, depth + 1, base, rootId, + composeNode(state, tree, prov, childIdx, childDepth, base, rootId, childrenAreArrayElements, node.id, childrenAreArrayElements ? elementIdx++ : -1); } } - // Footer line: skip when collapsed (only header shows) or for array element structs - if (!isArrayChild && !node.collapsed) { + // Footer line: skip when collapsed, for array element structs, or for root struct + if (!isArrayChild && !isRootHeader && !node.collapsed) { LineMeta lm; lm.nodeIdx = nodeIdx; lm.nodeId = node.id; @@ -464,6 +482,22 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { state.emitLine(QStringLiteral("File Address: 0x0"), lm); } + // Emit CommandRow2 as line 1 (root class type + name) + { + LineMeta lm; + lm.nodeIdx = -1; + lm.nodeId = kCommandRow2Id; + lm.depth = 0; + lm.lineKind = LineKind::CommandRow2; + lm.foldLevel = SC_FOLDLEVELBASE; + lm.foldHead = false; + lm.offsetText.clear(); + lm.markerMask = 0; + lm.effectiveTypeW = state.typeW; + lm.effectiveNameW = state.nameW; + state.emitLine(QStringLiteral("struct alignas(1)"), lm); + } + QVector roots = state.childMap.value(0); std::sort(roots.begin(), roots.end(), [&](int a, int b) { return tree.nodes[a].offset < tree.nodes[b].offset; @@ -515,4 +549,20 @@ QSet NodeTree::normalizePreferDescendants(const QSet& ids) c return result; } +int NodeTree::computeStructAlignment(uint64_t structId) const { + int idx = indexOfId(structId); + if (idx < 0) return 1; + int maxAlign = 1; + QVector kids = childrenOf(structId); + for (int ci : kids) { + const Node& c = nodes[ci]; + if (c.kind == NodeKind::Struct || c.kind == NodeKind::Array) { + maxAlign = qMax(maxAlign, computeStructAlignment(c.id)); + } else { + maxAlign = qMax(maxAlign, alignmentFor(c.kind)); + } + } + return maxAlign; +} + } // namespace rcx diff --git a/src/controller.cpp b/src/controller.cpp index b1ad128..a53e85d 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -175,8 +175,10 @@ void RcxController::connectEditor(RcxEditor* editor) { // Inline editing signals connect(editor, &RcxEditor::inlineEditCommitted, this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text) { - // CommandRow BaseAddress/Source edit has nodeIdx=-1 - if (nodeIdx < 0 && target != EditTarget::BaseAddress && target != EditTarget::Source) { refresh(); return; } + // CommandRow BaseAddress/Source edit has nodeIdx=-1; CommandRow2 edits too + if (nodeIdx < 0 && target != EditTarget::BaseAddress && target != EditTarget::Source + && target != EditTarget::RootClassType && target != EditTarget::RootClassName + && target != EditTarget::Alignas) { refresh(); return; } switch (target) { case EditTarget::Name: { if (text.isEmpty()) break; @@ -423,10 +425,59 @@ void RcxController::connectEditor(RcxEditor* editor) { } break; } + case EditTarget::RootClassType: { + QString kw = text.toLower().trimmed(); + if (kw != QStringLiteral("struct") && kw != QStringLiteral("class") && kw != QStringLiteral("enum")) break; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + auto& n = m_doc->tree.nodes[i]; + if (n.parentId == 0 && n.kind == NodeKind::Struct) { + QString oldKw = n.resolvedClassKeyword(); + if (oldKw != kw) { + m_doc->undoStack.push(new RcxCommand(this, + cmd::ChangeClassKeyword{n.id, oldKw, kw})); + } + break; + } + } + break; + } + case EditTarget::RootClassName: { + // Rename the root struct's structTypeName + if (!text.isEmpty()) { + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + auto& n = m_doc->tree.nodes[i]; + if (n.parentId == 0 && n.kind == NodeKind::Struct) { + QString oldName = n.structTypeName; + if (oldName != text) { + m_doc->undoStack.push(new RcxCommand(this, + cmd::ChangeStructTypeName{n.id, oldName, text})); + } + break; + } + } + } + break; + } case EditTarget::ArrayIndex: case EditTarget::ArrayCount: // Array navigation removed - these cases are unreachable break; + case EditTarget::Alignas: { + // Parse "alignas(N)" → N + int paren = text.indexOf('('); + int close = text.indexOf(')'); + if (paren < 0 || close < 0) break; + int newAlign = text.mid(paren + 1, close - paren - 1).toInt(); + if (newAlign <= 0) break; + 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) { + performRealignment(n.id, newAlign); + break; + } + } + break; + } } // Always refresh to restore canonical text (handles parse failures, no-ops, etc.) refresh(); @@ -447,11 +498,26 @@ void RcxController::refresh() { for (auto& lm : m_lastResult.meta) { if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue; int64_t offset = m_doc->tree.computeOffset(lm.nodeIdx); - int sz = m_doc->tree.nodes[lm.nodeIdx].byteSize(); - for (int64_t b = offset; b < offset + sz; b++) { - if (m_changedOffsets.contains(b)) { - lm.dataChanged = true; - break; + const Node& node = m_doc->tree.nodes[lm.nodeIdx]; + + if (isHexPreview(node.kind)) { + // Per-byte tracking for hex preview nodes + int lineOff = (node.kind == NodeKind::Padding) ? lm.subLine * 8 : 0; + int byteCount = lm.lineByteCount; + for (int b = 0; b < byteCount; b++) { + if (m_changedOffsets.contains(offset + lineOff + b)) { + lm.changedByteIndices.append(b); + lm.dataChanged = true; + } + } + } else { + // Existing boolean logic for non-hex nodes + int sz = node.byteSize(); + for (int64_t b = offset; b < offset + sz; b++) { + if (m_changedOffsets.contains(b)) { + lm.dataChanged = true; + break; + } } } } @@ -718,6 +784,14 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) tree.nodes[idx].structTypeName = isUndo ? c.oldName : c.newName; + } else if constexpr (std::is_same_v) { + int idx = tree.indexOfId(c.nodeId); + if (idx >= 0) + tree.nodes[idx].classKeyword = isUndo ? c.oldKeyword : c.newKeyword; + } else if constexpr (std::is_same_v) { + int idx = tree.indexOfId(c.nodeId); + if (idx >= 0) + tree.nodes[idx].offset = isUndo ? c.oldOffset : c.newOffset; } }, command); @@ -1052,7 +1126,7 @@ void RcxController::handleNodeClick(RcxEditor* source, int line, int to = qMax(m_anchorLine, line); for (int i = from; i <= to && i < m_lastResult.meta.size(); i++) { uint64_t nid = m_lastResult.meta[i].nodeId; - if (nid != 0 && nid != kCommandRowId) m_selIds.insert(effectiveId(i, nid)); + if (nid != 0 && nid != kCommandRowId && nid != kCommandRow2Id) m_selIds.insert(effectiveId(i, nid)); } } } else { // Ctrl+Shift @@ -1064,7 +1138,7 @@ void RcxController::handleNodeClick(RcxEditor* source, int line, int to = qMax(m_anchorLine, line); for (int i = from; i <= to && i < m_lastResult.meta.size(); i++) { uint64_t nid = m_lastResult.meta[i].nodeId; - if (nid != 0 && nid != kCommandRowId) m_selIds.insert(effectiveId(i, nid)); + if (nid != 0 && nid != kCommandRowId && nid != kCommandRow2Id) m_selIds.insert(effectiveId(i, nid)); } } } @@ -1092,6 +1166,100 @@ void RcxController::applySelectionOverlays() { editor->applySelectionOverlay(m_selIds); } +void RcxController::performRealignment(uint64_t structId, int targetAlign) { + auto& tree = m_doc->tree; + int rootIdx = tree.indexOfId(structId); + if (rootIdx < 0) return; + + // Gather direct children sorted by offset + QVector kids = tree.childrenOf(structId); + std::sort(kids.begin(), kids.end(), [&](int a, int b) { + return tree.nodes[a].offset < tree.nodes[b].offset; + }); + + // Separate into real nodes (non-Padding) and padding nodes + struct NodeInfo { uint64_t id; int offset; int size; }; + QVector realNodes; + QVector padIds; + + for (int ci : kids) { + const Node& child = tree.nodes[ci]; + int sz = (child.kind == NodeKind::Struct || child.kind == NodeKind::Array) + ? tree.structSpan(child.id) : child.byteSize(); + if (child.kind == NodeKind::Padding) + padIds.append(child.id); + else + realNodes.append({child.id, child.offset, sz}); + } + + auto roundUp = [](int x, int align) -> int { + return align <= 1 ? x : ((x + align - 1) / align) * align; + }; + + // Compute new offsets for real nodes + struct OffChange { uint64_t id; int oldOff; int newOff; }; + QVector offChanges; + int cursor = 0; + for (auto& rn : realNodes) { + int newOff = roundUp(cursor, targetAlign); + if (newOff != rn.offset) + offChanges.append({rn.id, rn.offset, newOff}); + rn.offset = newOff; // update local copy for gap computation + cursor = newOff + rn.size; + } + + // Compute where padding is needed (gaps between consecutive nodes) + struct PadInsert { int offset; int size; }; + QVector padsNeeded; + + for (int i = 0; i < realNodes.size(); i++) { + int gapStart = (i == 0) ? 0 : realNodes[i - 1].offset + realNodes[i - 1].size; + int gapEnd = realNodes[i].offset; + if (gapEnd > gapStart) + padsNeeded.append({gapStart, gapEnd - gapStart}); + } + + // Check if anything actually changes + if (offChanges.isEmpty() && padIds.isEmpty() && padsNeeded.isEmpty()) + return; + + // Apply as undoable macro + bool wasSuppressed = m_suppressRefresh; + m_suppressRefresh = true; + m_doc->undoStack.beginMacro(QStringLiteral("Realign to %1").arg(targetAlign)); + + // 1. Remove all existing Padding nodes (no offset adjustments — we recompute) + for (uint64_t pid : padIds) { + int idx = tree.indexOfId(pid); + if (idx < 0) continue; + QVector subtree; + subtree.append(tree.nodes[idx]); + m_doc->undoStack.push(new RcxCommand(this, + cmd::Remove{pid, subtree, {}})); + } + + // 2. Reposition real nodes + for (const auto& oc : offChanges) { + m_doc->undoStack.push(new RcxCommand(this, + cmd::ChangeOffset{oc.id, oc.oldOff, oc.newOff})); + } + + // 3. Insert new padding in gaps + for (const auto& pi : padsNeeded) { + Node pad; + pad.kind = NodeKind::Padding; + pad.parentId = structId; + pad.offset = pi.offset; + pad.arrayLen = pi.size; + pad.id = tree.reserveId(); + m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{pad})); + } + + m_doc->undoStack.endMacro(); + m_suppressRefresh = wasSuppressed; + if (!m_suppressRefresh) refresh(); +} + void RcxController::updateCommandRow() { // -- Source label: driven by provider metadata -- QString src; @@ -1128,8 +1296,26 @@ void RcxController::updateCommandRow() { .arg(elide(src, 40), elide(addr, 24), elide(sym, 40)); } - for (auto* ed : m_editors) + // Build row 2: root class type + name + alignment + 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) { + QString keyword = n.resolvedClassKeyword(); + QString className = n.structTypeName.isEmpty() ? n.name : n.structTypeName; + int alignment = m_doc->tree.computeStructAlignment(n.id); + row2 = QStringLiteral("%1 %2 alignas(%3)") + .arg(keyword, className).arg(alignment); + break; + } + } + if (row2.isEmpty()) + row2 = QStringLiteral("struct alignas(1)"); + + for (auto* ed : m_editors) { ed->setCommandRowText(row); + ed->setCommandRow2Text(row2); + } emit selectionChanged(m_selIds.size()); } diff --git a/src/controller.h b/src/controller.h index f35d81b..60bf74e 100644 --- a/src/controller.h +++ b/src/controller.h @@ -128,6 +128,7 @@ private: void connectEditor(RcxEditor* editor); void handleMarginClick(RcxEditor* editor, int margin, int line, Qt::KeyboardModifiers mods); void updateCommandRow(); + void performRealignment(uint64_t structId, int targetAlign); void attachToProcess(uint32_t pid, const QString& processName); void switchToSavedSource(int idx); void pushSavedSourcesToEditors(); diff --git a/src/core.h b/src/core.h index 47af15a..cc54d22 100644 --- a/src/core.h +++ b/src/core.h @@ -122,6 +122,9 @@ inline constexpr uint32_t flagsFor(NodeKind k) { inline constexpr bool isHexPreview(NodeKind k) { return flagsFor(k) & KF_HexPreview; } +inline constexpr bool isHexNode(NodeKind k) { + return k >= NodeKind::Hex8 && k <= NodeKind::Hex64; +} inline QStringList allTypeNamesForUI(bool stripBrackets = false) { QStringList out; @@ -157,6 +160,7 @@ struct Node { NodeKind kind = NodeKind::Hex8; QString name; QString structTypeName; // Struct/Array: optional type name (e.g., "IMAGE_DOS_HEADER") + QString classKeyword; // "struct", "class", or "enum" (empty = "struct") uint64_t parentId = 0; // 0 = root (no parent) int offset = 0; int arrayLen = 1; // Array: element count @@ -184,6 +188,8 @@ struct Node { o["name"] = name; if (!structTypeName.isEmpty()) o["structTypeName"] = structTypeName; + if (!classKeyword.isEmpty() && classKeyword != QStringLiteral("struct")) + o["classKeyword"] = classKeyword; o["parentId"] = QString::number(parentId); o["offset"] = offset; o["arrayLen"] = arrayLen; @@ -199,6 +205,7 @@ struct Node { n.kind = kindFromString(o["kind"].toString()); n.name = o["name"].toString(); n.structTypeName = o["structTypeName"].toString(); + n.classKeyword = o["classKeyword"].toString(); n.parentId = o["parentId"].toString("0").toULongLong(); n.offset = o["offset"].toInt(0); n.arrayLen = o["arrayLen"].toInt(1); @@ -209,6 +216,11 @@ struct Node { return n; } + // Resolved class keyword (never empty) + QString resolvedClassKeyword() const { + return classKeyword.isEmpty() ? QStringLiteral("struct") : classKeyword; + } + // Helper: is this a string-like array (char[] or wchar_t[])? bool isStringArray() const { return kind == NodeKind::Array && @@ -339,6 +351,9 @@ struct NodeTree { return qMax(declaredSize, maxEnd); } + // Compute natural alignment of a struct (max alignment of direct children) + int computeStructAlignment(uint64_t structId) const; + // Batch selection normalizers QSet normalizePreferAncestors(const QSet& ids) const; QSet normalizePreferDescendants(const QSet& ids) const; @@ -371,13 +386,16 @@ struct NodeTree { // ── LineMeta ── enum class LineKind : uint8_t { - CommandRow, // line 0 only, synthetic UI + CommandRow, // line 0: source + address + CommandRow2, // line 1: root class type + name Header, Field, Continuation, Footer, ArrayElementSeparator }; static constexpr uint64_t kCommandRowId = UINT64_MAX; +static constexpr uint64_t kCommandRow2Id = UINT64_MAX - 1; static constexpr int kCommandRowLine = 0; -static constexpr int kFirstDataLine = 1; +static constexpr int kCommandRow2Line = 1; +static constexpr int kFirstDataLine = 2; static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL; struct LineMeta { @@ -400,13 +418,15 @@ struct LineMeta { QString offsetText; uint32_t markerMask = 0; bool dataChanged = false; // true if any byte in this node changed since last refresh + QVector changedByteIndices; // Hex preview: which byte indices (0-based) changed on this line + int lineByteCount = 0; // Hex preview: actual data byte count on this line int effectiveTypeW = 14; // Per-line type column width used for rendering int effectiveNameW = 22; // Per-line name column width used for rendering QString pointerTargetName; // Resolved target type name for Pointer32/64 (empty = "void") }; inline bool isSyntheticLine(const LineMeta& lm) { - return lm.lineKind == LineKind::CommandRow; + return lm.lineKind == LineKind::CommandRow || lm.lineKind == LineKind::CommandRow2; } // ── Layout Info ── @@ -443,12 +463,15 @@ namespace cmd { struct ChangePointerRef { uint64_t nodeId; uint64_t oldRefId, newRefId; }; struct ChangeStructTypeName { uint64_t nodeId; QString oldName, newName; }; + struct ChangeClassKeyword { uint64_t nodeId; QString oldKeyword, newKeyword; }; + struct ChangeOffset { uint64_t nodeId; int oldOffset, newOffset; }; } using Command = std::variant< cmd::ChangeKind, cmd::Rename, cmd::Collapse, cmd::Insert, cmd::Remove, cmd::ChangeBase, cmd::WriteBytes, - cmd::ChangeArrayMeta, cmd::ChangePointerRef, cmd::ChangeStructTypeName + cmd::ChangeArrayMeta, cmd::ChangePointerRef, cmd::ChangeStructTypeName, + cmd::ChangeClassKeyword, cmd::ChangeOffset >; // ── Column spans (for inline editing) ── @@ -460,7 +483,8 @@ struct ColumnSpan { }; enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount, - ArrayElementType, ArrayElementCount, PointerTarget }; + ArrayElementType, ArrayElementCount, PointerTarget, + RootClassType, RootClassName, Alignas }; // Column layout constants (shared with format.cpp span computation) inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line @@ -562,6 +586,42 @@ inline ColumnSpan commandRowAddrSpan(const QString& lineText) { return {start, end, true}; } +// ── CommandRow2 spans ── +// Line format: "struct ClassName alignas(8)" + +inline ColumnSpan commandRow2TypeSpan(const QString& lineText) { + int start = 0; + while (start < lineText.size() && lineText[start].isSpace()) start++; + if (start >= lineText.size()) return {}; + int end = lineText.indexOf(' ', start); + if (end <= start) return {start, (int)lineText.size(), true}; + return {start, end, true}; +} + +inline ColumnSpan commandRow2NameSpan(const QString& lineText) { + int start = 0; + while (start < lineText.size() && lineText[start].isSpace()) start++; + int space = lineText.indexOf(' ', start); + if (space < 0) return {}; + int nameStart = space + 1; + while (nameStart < lineText.size() && lineText[nameStart].isSpace()) nameStart++; + if (nameStart >= lineText.size()) return {}; + // Name ends before "alignas(" if present, otherwise at line end + int nameEnd = lineText.indexOf(QStringLiteral(" alignas("), nameStart); + if (nameEnd < 0) nameEnd = lineText.size(); + while (nameEnd > nameStart && lineText[nameEnd - 1].isSpace()) nameEnd--; + if (nameEnd <= nameStart) return {}; + return {nameStart, nameEnd, true}; +} + +inline ColumnSpan commandRow2AlignasSpan(const QString& lineText) { + int idx = lineText.indexOf(QStringLiteral("alignas(")); + if (idx < 0) return {}; + int end = lineText.indexOf(')', idx); + if (end < 0) return {}; + return {idx, end + 1, true}; +} + // ── 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 9b7543b..b7ecee0 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -80,7 +80,9 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) { if (!m_editState.active) return; if (id == 1 && (m_editState.target == EditTarget::Type || m_editState.target == EditTarget::ArrayElementType - || m_editState.target == EditTarget::PointerTarget)) { + || m_editState.target == EditTarget::PointerTarget + || m_editState.target == EditTarget::RootClassType + || m_editState.target == EditTarget::Alignas)) { auto info = endInlineEdit(); emit inlineEditCommitted(info.nodeIdx, info.subLine, info.target, text); } @@ -105,8 +107,6 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) { } RcxEditor::~RcxEditor() { - if (m_cursorOverridden) - QApplication::restoreOverrideCursor(); } void RcxEditor::setupScintilla() { @@ -137,7 +137,7 @@ void RcxEditor::setupScintilla() { m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELFORE, (long)0, (long)0); m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELBACK, (long)0, (long)0); - // Editable-field indicator - set to HIDDEN (no visual, avoids INDIC_PLAIN underline) + // Editable-field indicator - HIDDEN (no visual) m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, IND_EDITABLE, 5 /*INDIC_HIDDEN*/); @@ -169,11 +169,11 @@ void RcxEditor::setupScintilla() { m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER, IND_CMD_PILL, (long)1); - // Data-changed indicator — amber text for values that changed since last refresh + // Data-changed indicator — muted green text (derived from number green #b5cea8) m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, IND_DATA_CHANGED, 17 /*INDIC_TEXTFORE*/); m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, - IND_DATA_CHANGED, QColor("#E5A00D")); + IND_DATA_CHANGED, QColor("#8fbc7a")); } @@ -359,7 +359,7 @@ void RcxEditor::applyMarkers(const QVector& meta) { } m_sci->markerDeleteAll(M_CMD_ROW); for (int i = 0; i < meta.size(); i++) { - if (meta[i].lineKind == LineKind::CommandRow) { + if (meta[i].lineKind == LineKind::CommandRow || meta[i].lineKind == LineKind::CommandRow2) { m_sci->markerAdd(i, M_CMD_ROW); continue; } @@ -557,12 +557,32 @@ void RcxEditor::applyDataChangedHighlight(const QVector& meta) { if (!meta[i].dataChanged) continue; if (isSyntheticLine(meta[i])) continue; - QString lineText = getLineText(m_sci, i); - int typeW = meta[i].effectiveTypeW; - int nameW = meta[i].effectiveNameW; - ColumnSpan vs = valueSpan(meta[i], lineText.size(), typeW, nameW); - if (vs.valid) - fillIndicatorCols(IND_DATA_CHANGED, i, vs.start, vs.end); + const LineMeta& lm = meta[i]; + int typeW = lm.effectiveTypeW; + int nameW = lm.effectiveNameW; + + if (isHexPreview(lm.nodeKind) && !lm.changedByteIndices.isEmpty()) { + // Per-byte highlighting in ASCII + hex areas + int ind = kFoldCol + lm.depth * 3; + int asciiStart = ind + typeW + kSepWidth; + // Hex8-64: ASCII always padded to 8; Padding: ASCII = lineByteCount chars + int asciiWidth = (lm.nodeKind == NodeKind::Padding) ? lm.lineByteCount : 8; + int hexStart = asciiStart + asciiWidth + kSepWidth; + + for (int byteIdx : lm.changedByteIndices) { + // Highlight in ASCII area (1 char per byte) + fillIndicatorCols(IND_DATA_CHANGED, i, asciiStart + byteIdx, asciiStart + byteIdx + 1); + // Highlight in hex area (2 hex chars per byte at position byteIdx*3) + int hexCol = hexStart + byteIdx * 3; + fillIndicatorCols(IND_DATA_CHANGED, i, hexCol, hexCol + 2); + } + } else { + // Non-hex nodes: highlight entire value span + QString lineText = getLineText(m_sci, i); + ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW); + if (vs.valid) + fillIndicatorCols(IND_DATA_CHANGED, i, vs.start, vs.end); + } } } @@ -583,6 +603,7 @@ void RcxEditor::applyCommandRowPills() { QString t = getLineText(m_sci, line); clearIndicatorLine(IND_CMD_PILL, line); + clearIndicatorLine(IND_HEX_DIM, line); auto fillPadded = [&](ColumnSpan s) { if (!s.valid) return; @@ -593,6 +614,48 @@ void RcxEditor::applyCommandRowPills() { fillPadded(commandRowSrcSpan(t)); fillPadded(commandRowAddrSpan(t)); + + // Dim label text: provider kind ("File"/"Process") and "Address:" + ColumnSpan srcSpan = commandRowSrcSpan(t); + if (srcSpan.valid) { + int quotePos = t.indexOf('\'', srcSpan.start); + int kindEnd = (quotePos > srcSpan.start) ? quotePos : srcSpan.end; + while (kindEnd > srcSpan.start && t[kindEnd - 1].isSpace()) kindEnd--; + if (kindEnd > srcSpan.start) + fillIndicatorCols(IND_HEX_DIM, line, srcSpan.start, kindEnd); + } + int addrTag = t.indexOf(QStringLiteral(" Address: ")); + if (addrTag >= 0) + fillIndicatorCols(IND_HEX_DIM, line, addrTag + 1, addrTag + 9); + + // Style CommandRow2 (line 1) if present + if (m_meta.size() > 1 && m_meta[1].lineKind == LineKind::CommandRow2) { + constexpr int line2 = 1; + QString t2 = getLineText(m_sci, line2); + + clearIndicatorLine(IND_CMD_PILL, line2); + clearIndicatorLine(IND_HEX_DIM, line2); + + auto fillPadded2 = [&](ColumnSpan s) { + if (!s.valid) return; + int a = qMax(0, s.start - 1); + int b = qMin(t2.size(), s.end + 1); + fillIndicatorCols(IND_CMD_PILL, line2, a, b); + }; + + ColumnSpan typeSpan = commandRow2TypeSpan(t2); + fillPadded2(typeSpan); + if (typeSpan.valid) + fillIndicatorCols(IND_HEX_DIM, line2, typeSpan.start, typeSpan.end); + + ColumnSpan nameSpan = commandRow2NameSpan(t2); + fillPadded2(nameSpan); + + ColumnSpan alignasSpan = commandRow2AlignasSpan(t2); + fillPadded2(alignasSpan); + if (alignasSpan.valid) + fillIndicatorCols(IND_HEX_DIM, line2, alignasSpan.start, alignasSpan.end); + } } // ── Shared inline-edit shutdown ── @@ -607,13 +670,8 @@ RcxEditor::EndEditInfo RcxEditor::endInlineEdit() { m_editState.active = false; m_sci->setReadOnly(true); m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 0); - // Switch from I-beam to Arrow (keep override active to block Scintilla's cursor) - if (m_cursorOverridden) { - QApplication::changeOverrideCursor(Qt::ArrowCursor); - } else { - QApplication::setOverrideCursor(Qt::ArrowCursor); - m_cursorOverridden = true; - } + // Switch back to Arrow cursor (widget-local, doesn't fight splitters/menus) + m_sci->viewport()->setCursor(Qt::ArrowCursor); // Disable selection rendering again m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELFORE, (long)0, (long)0); m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELBACK, (long)0, (long)0); @@ -753,11 +811,28 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t, return out.valid; } + // CommandRow2: root class type, name, and alignas + if (lm->lineKind == LineKind::CommandRow2) { + if (t != EditTarget::RootClassType && t != EditTarget::RootClassName + && t != EditTarget::Alignas) return false; + QString lineText = getLineText(m_sci, line); + ColumnSpan s; + if (t == EditTarget::RootClassType) s = commandRow2TypeSpan(lineText); + else if (t == EditTarget::RootClassName) s = commandRow2NameSpan(lineText); + else s = commandRow2AlignasSpan(lineText); + out = normalizeSpan(s, lineText, t, false); + if (lineTextOut) *lineTextOut = lineText; + return out.valid; + } + if (lm->nodeIdx < 0) return false; // Padding: reject value editing (hex bytes are display-only) if (t == EditTarget::Value && lm->nodeKind == NodeKind::Padding) return false; + // Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only) + if ((t == EditTarget::Name || t == EditTarget::Value) && isHexNode(lm->nodeKind)) + return false; QString lineText = getLineText(m_sci, line); int textLen = lineText.size(); @@ -868,6 +943,17 @@ static bool hitTestTarget(QsciScintilla* sci, return false; } + // CommandRow2: root class type, name, and alignas + if (lm.lineKind == LineKind::CommandRow2) { + ColumnSpan ts = commandRow2TypeSpan(lineText); + if (inSpan(ts)) { outTarget = EditTarget::RootClassType; outLine = line; return true; } + ColumnSpan ns = commandRow2NameSpan(lineText); + if (inSpan(ns)) { outTarget = EditTarget::RootClassName; outLine = line; return true; } + ColumnSpan as = commandRow2AlignasSpan(lineText); + if (inSpan(as)) { outTarget = EditTarget::Alignas; outLine = line; return true; } + return false; + } + // Use per-line effective widths from LineMeta int typeW = lm.effectiveTypeW; int nameW = lm.effectiveNameW; @@ -909,6 +995,9 @@ static bool hitTestTarget(QsciScintilla* sci, // Padding nodes: hex bytes are display-only, not editable if (outTarget == EditTarget::Value && lm.nodeKind == NodeKind::Padding) return false; + // Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only) + if ((outTarget == EditTarget::Name || outTarget == EditTarget::Value) && isHexNode(lm.nodeKind)) + return false; outLine = line; return true; @@ -995,7 +1084,7 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { return true; } // CommandRow: try ADDR edit or consume - if (h.nodeId == kCommandRowId) { + if (h.nodeId == kCommandRowId || h.nodeId == kCommandRow2Id) { int tLine; EditTarget t; if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, t)) beginInlineEdit(t, tLine); @@ -1032,6 +1121,7 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { m_pendingClickNodeId = 0; } } + return true; // consume ALL left-clicks (prevent QScintilla caret/cursor) } } // Drag-select: extend selection as mouse moves with button held @@ -1044,7 +1134,7 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { if (!m_dragStarted) { int dy = me->pos().y() - m_dragStartPos.y(); if (qAbs(dy) < 8) - return false; // not yet a drag, let Scintilla handle + return true; // not yet a drag, but still consume (don't let Scintilla handle) m_dragStarted = true; } @@ -1072,6 +1162,7 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { m_pendingClickMods); m_pendingClickNodeId = 0; } + return true; // consume release (prevent QScintilla from acting on it) } // Double-click during edit mode: select entire editable text if (obj == m_sci->viewport() && m_editState.active @@ -1088,10 +1179,11 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { m_pendingClickNodeId = 0; // cancel deferred selection change // Narrow selection to this node before editing auto h = hitTest(me->pos()); - if (h.nodeId != 0 && h.nodeId != kCommandRowId) + if (h.nodeId != 0 && h.nodeId != kCommandRowId && h.nodeId != kCommandRow2Id) emit nodeClicked(h.line, h.nodeId, Qt::NoModifier); return beginInlineEdit(t, line); } + return true; // consume even on miss (prevent QScintilla word-select) } if (obj == m_sci && event->type() == QEvent::FocusOut) { auto* fe = static_cast(event); @@ -1113,22 +1205,25 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { m_sci->getCursorPosition(&line, &col); updateEditableIndicators(line); } - if (obj == m_sci->viewport() && !m_editState.active) { + // Track mouse position for cursor updates (both edit and non-edit mode) + if (obj == m_sci->viewport()) { if (event->type() == QEvent::MouseMove) { m_lastHoverPos = static_cast(event)->pos(); m_hoverInside = true; } else if (event->type() == QEvent::Leave) { m_hoverInside = false; - m_hoveredNodeId = 0; - m_hoveredLine = -1; - applyHoverHighlight(); + if (!m_editState.active) { + m_hoveredNodeId = 0; + m_hoveredLine = -1; + applyHoverHighlight(); + } } else if (event->type() == QEvent::Wheel) { m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos()); m_hoverInside = m_sci->viewport()->rect().contains(m_lastHoverPos); } - // Resolve hovered nodeId on move/wheel - if (event->type() == QEvent::MouseMove - || event->type() == QEvent::Wheel) { + // Resolve hovered nodeId on move/wheel (non-edit mode only) + if (!m_editState.active && + (event->type() == QEvent::MouseMove || event->type() == QEvent::Wheel)) { auto h = hitTest(m_lastHoverPos); uint64_t newHoverId = (m_hoverInside && h.line >= 0) ? h.nodeId : 0; int newHoverLine = (m_hoverInside && h.line >= 0) ? h.line : -1; @@ -1138,10 +1233,16 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { applyHoverHighlight(); } } + // Update cursor on move/leave/wheel (both edit and non-edit mode) if (event->type() == QEvent::MouseMove || event->type() == QEvent::Leave || event->type() == QEvent::Wheel) applyHoverCursor(); + + // Consume MouseMove in non-edit mode so QScintilla's internal handler + // doesn't override our cursor (it resets to Arrow for read-only widgets) + if (!m_editState.active && event->type() == QEvent::MouseMove) + return true; } return QWidget::eventFilter(obj, event); } @@ -1264,13 +1365,19 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { m_sci->getCursorPosition(&line, &col); auto* lm = metaForLine(line); if (!lm) return false; - // Allow nodeIdx=-1 only for CommandRow BaseAddress/Source editing + // Allow nodeIdx=-1 only for CommandRow/CommandRow2 editing if (lm->nodeIdx < 0 && !(lm->lineKind == LineKind::CommandRow && - (target == EditTarget::BaseAddress || target == EditTarget::Source))) + (target == EditTarget::BaseAddress || target == EditTarget::Source)) + && !(lm->lineKind == LineKind::CommandRow2 && + (target == EditTarget::RootClassType || target == EditTarget::RootClassName + || target == EditTarget::Alignas))) return false; // Padding: reject value editing (display-only hex bytes) if (target == EditTarget::Value && lm->nodeKind == NodeKind::Padding) return false; + // Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only) + if ((target == EditTarget::Name || target == EditTarget::Value) && isHexNode(lm->nodeKind)) + return false; QString lineText; NormalizedSpan norm; @@ -1306,13 +1413,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { m_sci->setReadOnly(false); // Switch to I-beam for editing (skip for picker-based targets) if (target != EditTarget::Type && target != EditTarget::Source - && target != EditTarget::ArrayElementType && target != EditTarget::PointerTarget) { - if (m_cursorOverridden) { - QApplication::changeOverrideCursor(Qt::IBeamCursor); - } else { - QApplication::setOverrideCursor(Qt::IBeamCursor); - m_cursorOverridden = true; - } + && target != EditTarget::ArrayElementType && target != EditTarget::PointerTarget + && target != EditTarget::RootClassType) { + m_sci->viewport()->setCursor(Qt::IBeamCursor); } // Re-enable selection rendering for inline edit @@ -1342,6 +1445,40 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { QTimer::singleShot(0, this, &RcxEditor::showSourcePicker); if (target == EditTarget::PointerTarget) QTimer::singleShot(0, this, &RcxEditor::showPointerTargetPicker); + if (target == EditTarget::RootClassType) { + QTimer::singleShot(0, this, [this]() { + if (!m_editState.active || m_editState.target != EditTarget::RootClassType) return; + // Replace text with spaces and show picker + int len = m_editState.original.size(); + QString spaces(len, ' '); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL, + m_editState.posStart, m_editState.posEnd); + m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL, + (uintptr_t)0, spaces.toUtf8().constData()); + m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, m_editState.posStart); + m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n'); + m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW, + (uintptr_t)1, "struct\nclass\nenum"); + m_sci->viewport()->setCursor(Qt::ArrowCursor); + }); + } + if (target == EditTarget::Alignas) { + QTimer::singleShot(0, this, [this]() { + if (!m_editState.active || m_editState.target != EditTarget::Alignas) return; + int len = m_editState.original.size(); + QString spaces(len, ' '); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL, + m_editState.posStart, m_editState.posEnd); + m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL, + (uintptr_t)0, spaces.toUtf8().constData()); + m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, m_editState.posStart); + m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n'); + m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW, + (uintptr_t)1, + "alignas(1)\nalignas(4)\nalignas(8)\nalignas(16)"); + m_sci->viewport()->setCursor(Qt::ArrowCursor); + }); + } return true; } @@ -1469,7 +1606,8 @@ void RcxEditor::showTypeListFiltered(const QString& filter) { m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n'); m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW, (uintptr_t)1, list.constData()); - // Arrow cursor for popup is handled by applyHoverCursor() via isListActive() + // Force Arrow cursor immediately (don't wait for mouse move) + m_sci->viewport()->setCursor(Qt::ArrowCursor); } void RcxEditor::showSourcePicker() { @@ -1574,6 +1712,8 @@ void RcxEditor::showPointerTargetListFiltered(const QString& filter) { m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n'); m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW, (uintptr_t)1, list.constData()); + // Force Arrow cursor immediately (don't wait for mouse move) + m_sci->viewport()->setCursor(Qt::ArrowCursor); } void RcxEditor::updatePointerTargetFilter() { @@ -1599,7 +1739,7 @@ void RcxEditor::paintEditableSpans(int line) { const LineMeta* lm = metaForLine(line); if (!lm) return; // CommandRow: paint Source and BaseAddress spans - if (isSyntheticLine(*lm)) { + if (lm->lineKind == LineKind::CommandRow) { NormalizedSpan norm; if (resolvedSpanFor(line, EditTarget::Source, norm)) fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end); @@ -1607,6 +1747,18 @@ void RcxEditor::paintEditableSpans(int line) { fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end); return; } + // CommandRow2: paint RootClassType, RootClassName, and Alignas spans + if (lm->lineKind == LineKind::CommandRow2) { + NormalizedSpan norm; + if (resolvedSpanFor(line, EditTarget::RootClassType, norm)) + fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end); + if (resolvedSpanFor(line, EditTarget::RootClassName, norm)) + fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end); + if (resolvedSpanFor(line, EditTarget::Alignas, norm)) + fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end); + return; + } + if (isSyntheticLine(*lm)) return; NormalizedSpan norm; for (EditTarget t : {EditTarget::Type, EditTarget::Name, EditTarget::Value, EditTarget::ArrayElementType, EditTarget::ArrayElementCount, @@ -1669,29 +1821,37 @@ void RcxEditor::applyHoverCursor() { clearIndicatorLine(IND_HOVER_SPAN, ln); m_hoverSpanLines.clear(); - // Edit mode handles its own cursor (I-beam) - if (m_editState.active) + // Lock cursor to Arrow during drag-selection (prevents flicker) + if (m_dragStarted) { + m_sci->viewport()->setCursor(Qt::ArrowCursor); return; + } + + // Edit mode: IBeam inside edit span, Arrow outside + if (m_editState.active) { + if (m_sci->isListActive()) { + m_sci->viewport()->setCursor(Qt::ArrowCursor); + return; + } + auto h = hitTest(m_lastHoverPos); + if (h.line == m_editState.line && + h.col >= m_editState.spanStart && h.col <= editEndCol()) { + m_sci->viewport()->setCursor(Qt::IBeamCursor); + } else { + m_sci->viewport()->setCursor(Qt::ArrowCursor); + } + return; + } // Mouse left viewport - set Arrow - if (!m_hoverInside || !m_sci->viewport()->underMouse()) { - if (!m_cursorOverridden) { - QApplication::setOverrideCursor(Qt::ArrowCursor); - m_cursorOverridden = true; - } else { - QApplication::changeOverrideCursor(Qt::ArrowCursor); - } + if (!m_hoverInside) { + m_sci->viewport()->setCursor(Qt::ArrowCursor); return; } // If autocomplete/user list popup is active, use arrow cursor if (m_sci->isListActive()) { - if (!m_cursorOverridden) { - QApplication::setOverrideCursor(Qt::ArrowCursor); - m_cursorOverridden = true; - } else { - QApplication::changeOverrideCursor(Qt::ArrowCursor); - } + m_sci->viewport()->setCursor(Qt::ArrowCursor); return; } @@ -1699,34 +1859,13 @@ void RcxEditor::applyHoverCursor() { int line; EditTarget t; bool tokenHit = hitTestTarget(m_sci, m_meta, m_lastHoverPos, line, t); - // For hex preview nodes, check if cursor is in the data area (ASCII or hex bytes) + // Skip hover span on footer lines (nothing editable) int hoverLine = h.line; - bool inHexDataArea = false; - uint64_t hoverNodeId = 0; - if (hoverLine >= 0 && hoverLine < m_meta.size() - && isHexPreview(m_meta[hoverLine].nodeKind)) { - hoverNodeId = m_meta[hoverLine].nodeId; - if (hoverNodeId != 0 && h.col >= 0) { - int ind = kFoldCol + m_meta[hoverLine].depth * 3; - int typeW = m_meta[hoverLine].effectiveTypeW; - int dataStart = ind + typeW + kSepWidth; - inHexDataArea = (h.col >= dataStart); - } - } + bool isFooterLine = (hoverLine >= 0 && hoverLine < m_meta.size() + && m_meta[hoverLine].lineKind == LineKind::Footer); - // Apply hover span indicator - if (inHexDataArea) { - // Hex preview nodes: highlight ASCII + hex byte areas on ALL lines of this node - for (int i = 0; i < m_meta.size(); i++) { - if (m_meta[i].nodeId != hoverNodeId) continue; - int ind = kFoldCol + m_meta[i].depth * 3; - int typeW = m_meta[i].effectiveTypeW; - int asciiStart = ind + typeW + kSepWidth; - int hexEnd = asciiStart + 8 + kSepWidth + 23; - fillIndicatorCols(IND_HOVER_SPAN, i, asciiStart, hexEnd); - m_hoverSpanLines.append(i); - } - } else if (tokenHit) { + // Apply hover span indicator for editable tokens + if (tokenHit && !isFooterLine) { NormalizedSpan span; if (resolvedSpanFor(line, t, span)) { fillIndicatorCols(IND_HOVER_SPAN, line, span.start, span.end); @@ -1734,20 +1873,35 @@ void RcxEditor::applyHoverCursor() { } } - // Also show pointer cursor for fold column on fold-head lines - bool interactive = tokenHit || inHexDataArea; - if (!interactive) { - if (h.inFoldCol) interactive = true; + // Determine cursor shape based on interaction type + Qt::CursorShape desired = Qt::ArrowCursor; + + if (h.inFoldCol) { + desired = Qt::PointingHandCursor; // fold toggle = button + } else if (tokenHit) { + // Check if mouse is actually over trimmed text content (not column padding) + NormalizedSpan trimmed; + bool overText = resolvedSpanFor(line, t, trimmed) + && h.col >= trimmed.start && h.col < trimmed.end; + if (overText) { + switch (t) { + case EditTarget::Type: + case EditTarget::Source: + case EditTarget::ArrayElementType: + case EditTarget::PointerTarget: + case EditTarget::RootClassType: + case EditTarget::Alignas: + desired = Qt::PointingHandCursor; + break; + default: + desired = Qt::IBeamCursor; + break; + } + } + // else: desired stays Arrow (hovering over column padding) } - // Set cursor: pointing hand for interactive, arrow otherwise - Qt::CursorShape desired = interactive ? Qt::PointingHandCursor : Qt::ArrowCursor; - if (!m_cursorOverridden) { - QApplication::setOverrideCursor(desired); - m_cursorOverridden = true; - } else { - QApplication::changeOverrideCursor(desired); - } + m_sci->viewport()->setCursor(desired); } // ── Live value validation ── @@ -1851,6 +2005,36 @@ void RcxEditor::setCommandRowText(const QString& line) { applyCommandRowPills(); } +void RcxEditor::setCommandRow2Text(const QString& line) { + if (m_sci->lines() <= 1) return; + QString s = line; + s.replace('\n', ' '); + s.replace('\r', ' '); + + bool wasReadOnly = m_sci->isReadOnly(); + bool wasModified = m_sci->SendScintilla(QsciScintillaBase::SCI_GETMODIFY); + long savedPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS); + long savedAnchor = m_sci->SendScintilla(QsciScintillaBase::SCI_GETANCHOR); + + m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, 0); + m_sci->setReadOnly(false); + + long start = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 1); + long end = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, 1); + QByteArray utf8 = s.toUtf8(); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, start); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, end); + m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET, (uintptr_t)utf8.size(), utf8.constData()); + + if (wasReadOnly) m_sci->setReadOnly(true); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, 1); + if (!wasModified) m_sci->SendScintilla(QsciScintillaBase::SCI_SETSAVEPOINT); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETCURRENTPOS, savedPos); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETANCHOR, savedAnchor); + m_sci->SendScintilla(QsciScintillaBase::SCI_COLOURISE, start, start + utf8.size()); + applyCommandRowPills(); +} + void RcxEditor::setEditorFont(const QString& fontName) { g_fontName = fontName; QFont f = editorFont(); diff --git a/src/editor.h b/src/editor.h index e2c4989..7b62282 100644 --- a/src/editor.h +++ b/src/editor.h @@ -44,6 +44,7 @@ public: void applySelectionOverlay(const QSet& selIds); void setCommandRowText(const QString& line); + void setCommandRow2Text(const QString& line); void setEditorFont(const QString& fontName); static void setGlobalFontName(const QString& fontName); @@ -76,7 +77,6 @@ private: // ── Hover cursor + highlight ── QPoint m_lastHoverPos; bool m_hoverInside = false; - bool m_cursorOverridden = false; uint64_t m_hoveredNodeId = 0; int m_hoveredLine = -1; QSet m_currentSelIds; diff --git a/src/generator.cpp b/src/generator.cpp index 661223b..f07739a 100644 --- a/src/generator.cpp +++ b/src/generator.cpp @@ -261,7 +261,9 @@ static void emitStruct(GenContext& ctx, uint64_t structId) { if (refIdx >= 0 && !ctx.emittedIds.contains(child.refId) && !ctx.forwardDeclared.contains(child.refId)) { QString fwdName = ctx.structName(ctx.tree.nodes[refIdx]); - ctx.output += QStringLiteral("struct %1;\n").arg(fwdName); + QString fwdKw = ctx.tree.nodes[refIdx].resolvedClassKeyword(); + if (fwdKw == QStringLiteral("enum")) fwdKw = QStringLiteral("struct"); + ctx.output += QStringLiteral("%1 %2;\n").arg(fwdKw, fwdName); ctx.forwardDeclared.insert(child.refId); } } @@ -273,7 +275,9 @@ static void emitStruct(GenContext& ctx, uint64_t structId) { int structSize = ctx.tree.structSpan(structId, &ctx.childMap); ctx.output += QStringLiteral("#pragma pack(push, 1)\n"); - ctx.output += QStringLiteral("struct %1 {\n").arg(typeName); + QString kw = node.resolvedClassKeyword(); + if (kw == QStringLiteral("enum")) kw = QStringLiteral("struct"); // enum is cosmetic + ctx.output += QStringLiteral("%1 %2 {\n").arg(kw, typeName); emitStructBody(ctx, structId); diff --git a/src/main.cpp b/src/main.cpp index 28b7bf0..63d4804 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -107,23 +107,16 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { #include #include -struct TestLiveData { - int32_t valA = 100; - int32_t valB = 200; - int32_t valC = 300; - int32_t valD = 400; -}; - -static TestLiveData* g_testData = nullptr; +static uint8_t* g_testData = nullptr; +static constexpr int kTestDataSize = 128; static std::atomic g_testRunning{false}; static void testLiveThread() { std::mt19937 rng(42); - std::uniform_int_distribution dist(0, 3); + std::uniform_int_distribution dist(0, kTestDataSize - 1); while (g_testRunning.load()) { std::this_thread::sleep_for(std::chrono::seconds(1)); - int32_t* fields = &g_testData->valA; - fields[dist(rng)]++; + g_testData[dist(rng)]++; } } @@ -411,8 +404,8 @@ void MainWindow::newFile() { void MainWindow::selfTest() { #ifdef _WIN32 - // Allocate test struct — lives until process exit - g_testData = new TestLiveData(); + // Allocate 128 bytes — lives until process exit + g_testData = new uint8_t[kTestDataSize](); g_testRunning = true; std::thread(testLiveThread).detach(); @@ -424,22 +417,26 @@ void MainWindow::selfTest() { | PROCESS_QUERY_INFORMATION, FALSE, GetCurrentProcessId()); doc->provider = std::make_shared( - hProc, base, (int)sizeof(TestLiveData), "ReclassX.exe"); + hProc, base, kTestDataSize, "ReclassX.exe"); doc->tree.baseAddress = base; Node root; root.kind = NodeKind::Struct; - root.name = "TestLiveData"; - root.structTypeName = "TestLiveData"; + root.name = "MyClass"; + root.structTypeName = "MyClass"; 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 = "valA"; n.parentId = rootId; n.offset = 0; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "valB"; n.parentId = rootId; n.offset = 4; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "valC"; n.parentId = rootId; n.offset = 8; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "valD"; n.parentId = rootId; n.offset = 12; doc->tree.addNode(n); } + for (int i = 0; i < 16; i++) { + Node n; + n.kind = NodeKind::Hex64; + n.name = QStringLiteral("field_%1").arg(i); + n.parentId = rootId; + n.offset = i * 8; + doc->tree.addNode(n); + } createTab(doc); #endif diff --git a/tests/test_compose.cpp b/tests/test_compose.cpp index 69a4e1d..6976946 100644 --- a/tests/test_compose.cpp +++ b/tests/test_compose.cpp @@ -35,30 +35,22 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // CommandRow + Header + 2 fields + footer = 5 lines - QCOMPARE(result.meta.size(), 5); + // CommandRow + CommandRow2 + 2 fields = 4 lines (root header/footer suppressed) + QCOMPARE(result.meta.size(), 4); // Line 0 is CommandRow QCOMPARE(result.meta[0].lineKind, LineKind::CommandRow); - // Header is fold head - QVERIFY(result.meta[1].foldHead); - QCOMPARE(result.meta[1].lineKind, LineKind::Header); + // Line 1 is CommandRow2 + QCOMPARE(result.meta[1].lineKind, LineKind::CommandRow2); - // Fields are not fold heads + // Fields at depth 0 (root struct suppressed) QVERIFY(!result.meta[2].foldHead); QVERIFY(!result.meta[3].foldHead); - // Footer - QCOMPARE(result.meta[4].lineKind, LineKind::Footer); - // Offset text - QCOMPARE(result.meta[1].offsetText, QString("0")); QCOMPARE(result.meta[2].offsetText, QString("0")); QCOMPARE(result.meta[3].offsetText, QString("4")); - - // Header is expanded by default (fold indicator in line text) - QVERIFY(!result.meta[1].foldCollapsed); } void testVec3Continuation() { @@ -82,8 +74,8 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // CommandRow + Header + 3 Vec3 lines + footer = 6 lines - QCOMPARE(result.meta.size(), 6); + // CommandRow + CommandRow2 + 3 Vec3 lines = 5 lines (root header/footer suppressed) + QCOMPARE(result.meta.size(), 5); // Line 2 (first Vec3 component): not continuation QVERIFY(!result.meta[2].isContinuation); @@ -121,8 +113,8 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // CommandRow + Header + padding + footer = 4 - QCOMPARE(result.meta.size(), 4); + // CommandRow + CommandRow2 + padding = 3 (root header/footer suppressed) + QCOMPARE(result.meta.size(), 3); QVERIFY(result.meta[2].markerMask & (1u << M_PAD)); } @@ -149,7 +141,8 @@ private slots: BufferProvider prov(data); ComposeResult result = compose(tree, prov); - QCOMPARE(result.meta.size(), 4); + // CommandRow + CommandRow2 + ptr = 3 (root header/footer suppressed) + QCOMPARE(result.meta.size(), 3); // No ambient validation markers — M_PTR0 is no longer set QVERIFY(!(result.meta[2].markerMask & (1u << M_PTR0))); } @@ -176,10 +169,10 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // Collapsed: CommandRow + header only (no children, no footer) - QCOMPARE(result.meta.size(), 2); - QVERIFY(result.meta[1].foldHead); - QVERIFY(result.meta[1].foldCollapsed); + // Collapsed: CommandRow + CommandRow2 + header only (no children, no footer) + QCOMPARE(result.meta.size(), 3); + QVERIFY(!result.meta[2].foldHead); // root fold suppressed + QVERIFY(!result.meta[2].foldCollapsed); // root fold suppressed } void testUnreadablePointerNoRead() { @@ -206,7 +199,8 @@ private slots: BufferProvider prov(data); ComposeResult result = compose(tree, prov); - QCOMPARE(result.meta.size(), 4); + // CommandRow + CommandRow2 + ptr = 3 (root header/footer suppressed) + QCOMPARE(result.meta.size(), 3); // No ambient validation markers QVERIFY(!(result.meta[2].markerMask & (1u << M_ERR))); QVERIFY(!(result.meta[2].markerMask & (1u << M_PTR0))); @@ -241,17 +235,14 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // Root header (depth 0, head) -> 0x400 | 0x2000 - QCOMPARE(result.meta[1].foldLevel, 0x400 | 0x2000); - QCOMPARE(result.meta[1].depth, 0); + // Child header (depth 0, fold head) — root suppressed, children at depth 0 + QCOMPARE(result.meta[2].foldLevel, 0x400 | 0x2000); + QCOMPARE(result.meta[2].depth, 0); + QVERIFY(result.meta[2].foldHead); - // Child header (depth 1, head) -> 0x401 | 0x2000 - QCOMPARE(result.meta[2].foldLevel, 0x401 | 0x2000); - QCOMPARE(result.meta[2].depth, 1); - - // Leaf (depth 2, not head) -> 0x402 - QCOMPARE(result.meta[3].foldLevel, 0x402); - QCOMPARE(result.meta[3].depth, 2); + // Leaf (depth 1, not head) + QCOMPARE(result.meta[3].foldLevel, 0x401); + QCOMPARE(result.meta[3].depth, 1); } void testNestedStruct() { @@ -298,36 +289,28 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // CommandRow + Outer header + flags + Inner header + x + y + Inner footer + Outer footer = 8 - QCOMPARE(result.meta.size(), 8); + // CommandRow + CommandRow2 + flags + Inner header + x + y + Inner footer = 7 + // (root header/footer suppressed, children at depth 0) + QCOMPARE(result.meta.size(), 7); - // Outer header - QCOMPARE(result.meta[1].lineKind, LineKind::Header); - QCOMPARE(result.meta[1].depth, 0); - QVERIFY(result.meta[1].foldHead); - - // flags field + // flags field (depth 0, root children at depth 0) QCOMPARE(result.meta[2].lineKind, LineKind::Field); - QCOMPARE(result.meta[2].depth, 1); + QCOMPARE(result.meta[2].depth, 0); - // Inner header + // Inner header (depth 0, fold head) QCOMPARE(result.meta[3].lineKind, LineKind::Header); - QCOMPARE(result.meta[3].depth, 1); + QCOMPARE(result.meta[3].depth, 0); QVERIFY(result.meta[3].foldHead); - QCOMPARE(result.meta[3].foldLevel, 0x401 | 0x2000); + QCOMPARE(result.meta[3].foldLevel, 0x400 | 0x2000); - // Inner fields at depth 2 - QCOMPARE(result.meta[4].depth, 2); - QCOMPARE(result.meta[4].foldLevel, 0x402); - QCOMPARE(result.meta[5].depth, 2); + // Inner fields at depth 1 + QCOMPARE(result.meta[4].depth, 1); + QCOMPARE(result.meta[4].foldLevel, 0x401); + QCOMPARE(result.meta[5].depth, 1); // Inner footer QCOMPARE(result.meta[6].lineKind, LineKind::Footer); - QCOMPARE(result.meta[6].depth, 1); - - // Outer footer - QCOMPARE(result.meta[7].lineKind, LineKind::Footer); - QCOMPARE(result.meta[7].depth, 0); + QCOMPARE(result.meta[6].depth, 0); } void testPointerDerefExpansion() { @@ -395,36 +378,28 @@ private slots: ComposeResult result = compose(tree, prov); - // CommandRow + Main: header + magic + ptr(merged fold header) + fn1 + fn2 + ptr footer + Main footer = 8 + // CommandRow + CommandRow2 + magic + ptr(merged fold header) + fn1 + fn2 + ptr footer = 7 // VTable standalone: header + fn1 + fn2 + footer = 4 - // Total = 12 - QCOMPARE(result.meta.size(), 12); + // Total = 11 (root header/footer suppressed) + QCOMPARE(result.meta.size(), 11); - // Main header - QCOMPARE(result.meta[1].lineKind, LineKind::Header); - QCOMPARE(result.meta[1].depth, 0); - - // magic field + // magic field (depth 0, root children at depth 0) QCOMPARE(result.meta[2].lineKind, LineKind::Field); - QCOMPARE(result.meta[2].depth, 1); + QCOMPARE(result.meta[2].depth, 0); // Pointer as merged fold header: "ptr64 ptr {" QCOMPARE(result.meta[3].lineKind, LineKind::Header); - QCOMPARE(result.meta[3].depth, 1); + QCOMPARE(result.meta[3].depth, 0); QVERIFY(result.meta[3].foldHead); QCOMPARE(result.meta[3].nodeKind, NodeKind::Pointer64); - // Expanded fields at depth 2 (struct header merged into pointer) - QCOMPARE(result.meta[4].depth, 2); - QCOMPARE(result.meta[5].depth, 2); + // Expanded fields at depth 1 (struct header merged into pointer) + QCOMPARE(result.meta[4].depth, 1); + QCOMPARE(result.meta[5].depth, 1); // Pointer fold footer QCOMPARE(result.meta[6].lineKind, LineKind::Footer); - QCOMPARE(result.meta[6].depth, 1); - - // Main footer - QCOMPARE(result.meta[7].lineKind, LineKind::Footer); - QCOMPARE(result.meta[7].depth, 0); + QCOMPARE(result.meta[6].depth, 0); } void testPointerDerefNull() { @@ -468,10 +443,10 @@ private slots: ComposeResult result = compose(tree, prov); - // CommandRow + Main: header + ptr(merged fold header) + ptr footer + Main footer = 5 + // CommandRow + CommandRow2 + ptr(merged fold header) + ptr footer = 4 // Target standalone: header + field + footer = 3 - // Total = 8 - QCOMPARE(result.meta.size(), 8); + // Total = 7 (root header/footer suppressed) + QCOMPARE(result.meta.size(), 7); // Pointer as merged fold header (expanded but empty — null ptr) QCOMPARE(result.meta[2].lineKind, LineKind::Header); @@ -479,10 +454,6 @@ private slots: // Pointer fold footer (empty expansion) QCOMPARE(result.meta[3].lineKind, LineKind::Footer); - - // Main footer - QCOMPARE(result.meta[4].lineKind, LineKind::Footer); - QCOMPARE(result.meta[4].depth, 0); } void testPointerDerefCollapsed() { @@ -529,17 +500,14 @@ private slots: ComposeResult result = compose(tree, prov); - // CommandRow + Main: header + ptr(fold head, collapsed) + footer = 4 + // CommandRow + CommandRow2 + ptr(fold head, collapsed) = 3 // Target standalone: header + field + footer = 3 - // Total = 7 - QCOMPARE(result.meta.size(), 7); + // Total = 6 (root header/footer suppressed) + QCOMPARE(result.meta.size(), 6); - // Pointer is fold head + // Pointer is fold head (depth 0, root children at depth 0) QVERIFY(result.meta[2].foldHead); - - // No expansion — next is Main footer - QCOMPARE(result.meta[3].lineKind, LineKind::Footer); - QCOMPARE(result.meta[3].depth, 0); + QCOMPARE(result.meta[2].depth, 0); } void testPointerDerefCycle() { @@ -602,47 +570,58 @@ private slots: QVERIFY(result.meta.size() > 0); QVERIFY(result.meta.size() < 100); // sanity: bounded output - // First expansion: CommandRow + Main header + ptr merged header + data + self merged header + // Root suppressed: CommandRow + CommandRow2 + ptr merged header + data + self merged header // Second expansion blocked by cycle guard: no children under self - // Then: self footer + ptr footer + Main footer - // Plus standalone Recursive rendering - // The exact count depends on cycle guard behavior but must be finite - QCOMPARE(result.meta[1].lineKind, LineKind::Header); // Main header + // Then: self footer + ptr footer + standalone Recursive rendering QVERIFY(result.meta[2].foldHead); // ptr merged fold head QCOMPARE(result.meta[2].lineKind, LineKind::Header); // ptr merged header QCOMPARE(result.meta[3].lineKind, LineKind::Field); // data field (first child of Recursive) } void testStructFooterSimple() { + // Root footer is suppressed; test nested struct footer instead NodeTree tree; tree.baseAddress = 0; Node root; root.kind = NodeKind::Struct; - root.name = "Sized"; + root.name = "Root"; root.parentId = 0; - root.offset = 0; int ri = tree.addNode(root); uint64_t rootId = tree.nodes[ri].id; + Node inner; + inner.kind = NodeKind::Struct; + inner.name = "Inner"; + inner.parentId = rootId; + inner.offset = 0; + int ii = tree.addNode(inner); + uint64_t innerId = tree.nodes[ii].id; + Node f1; f1.kind = NodeKind::UInt32; f1.name = "a"; - f1.parentId = rootId; + f1.parentId = innerId; f1.offset = 0; tree.addNode(f1); NullProvider prov; ComposeResult result = compose(tree, prov); - // Footer is the last line - int lastLine = result.meta.size() - 1; - QCOMPARE(result.meta[lastLine].lineKind, LineKind::Footer); + // Find a footer line (nested struct footer) + int footerLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].lineKind == LineKind::Footer) { + footerLine = i; + break; + } + } + QVERIFY2(footerLine >= 0, "Should have a footer for nested struct"); - // Footer text should just be "};" (no sizeof) - QString footerText = result.text.split('\n').last(); - QVERIFY(footerText.contains("};")); - QVERIFY(!footerText.contains("sizeof")); + // Footer text should contain "};" (no sizeof) + QStringList lines = result.text.split('\n'); + QVERIFY(lines[footerLine].contains("};")); + QVERIFY(!lines[footerLine].contains("sizeof")); } void testLineMetaHasNodeId() { @@ -660,12 +639,17 @@ private slots: ComposeResult result = compose(tree, prov); for (int i = 0; i < result.meta.size(); i++) { - // Skip CommandRow (synthetic line with sentinel nodeId) + // Skip CommandRow / CommandRow2 (synthetic lines with sentinel nodeId) if (result.meta[i].lineKind == LineKind::CommandRow) { QCOMPARE(result.meta[i].nodeId, kCommandRowId); QCOMPARE(result.meta[i].nodeIdx, -1); continue; } + if (result.meta[i].lineKind == LineKind::CommandRow2) { + QCOMPARE(result.meta[i].nodeId, kCommandRow2Id); + QCOMPARE(result.meta[i].nodeIdx, -1); + continue; + } QVERIFY2(result.meta[i].nodeId != 0, qPrintable(QString("Line %1 has nodeId=0").arg(i))); int ni = result.meta[i].nodeIdx; @@ -942,8 +926,8 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // CommandRow + Root header + Array header(collapsed) + Root footer = 4 - QCOMPARE(result.meta.size(), 4); + // CommandRow + CommandRow2 + Array header(collapsed) = 3 (root header/footer suppressed) + QCOMPARE(result.meta.size(), 3); // Array header is collapsed int arrLine = -1; @@ -1138,12 +1122,11 @@ private slots: NullProvider prov; ComposeResult result = compose(tree, prov); - // Find the pointer line + // Find the pointer line (root children at depth 0 due to root suppression) int ptrLine = -1; for (int i = 0; i < result.meta.size(); i++) { if (result.meta[i].nodeKind == NodeKind::Pointer64 && - result.meta[i].lineKind == LineKind::Field && - result.meta[i].depth > 0) { + result.meta[i].lineKind == LineKind::Field) { ptrLine = i; break; } @@ -1239,8 +1222,7 @@ private slots: int ptrLine = -1; for (int i = 0; i < result.meta.size(); i++) { if (result.meta[i].nodeKind == NodeKind::Pointer64 && - result.meta[i].lineKind == LineKind::Field && - result.meta[i].depth > 0) { + result.meta[i].lineKind == LineKind::Field) { ptrLine = i; break; } @@ -1500,14 +1482,9 @@ private slots: QVERIFY2(foundToMain, "Should display 'ptr64
'"); // The first expansion of each pointer works; - // the cycle is caught on the second attempt - int mainHeaders = 0; - for (const LineMeta& lm : result.meta) { - if (lm.lineKind == LineKind::Header && lm.nodeIdx == mi) - mainHeaders++; - } - // Main appears as root + expanded once from StructB, then blocked on re-expansion - QVERIFY2(mainHeaders >= 1, "Main should appear at least once"); + // the cycle is caught on the second attempt. + // Main root header is suppressed, and pointer deref uses isArrayChild=true + // (which also skips headers), so we verify cycle detection by bounded output above. } void testAllStructsResolvedAsPointerTargets() { @@ -1716,6 +1693,144 @@ private slots: qPrintable(QString("typeW=%1, should be >= 35").arg(result.layout.typeW))); } + // ═════════════════════════════════════════════════════════════ + // Class keyword + alignment tests + // ═════════════════════════════════════════════════════════════ + + void testClassKeywordJsonRoundTrip() { + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + root.classKeyword = "class"; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node f; + f.kind = NodeKind::Hex32; + f.name = "x"; + f.parentId = rootId; + f.offset = 0; + tree.addNode(f); + + // Save and reload + QJsonObject json = tree.toJson(); + NodeTree tree2 = NodeTree::fromJson(json); + + // Find the root struct in the reloaded tree + bool found = false; + for (const auto& n : tree2.nodes) { + if (n.kind == NodeKind::Struct && n.name == "Root") { + QCOMPARE(n.classKeyword, QString("class")); + QCOMPARE(n.resolvedClassKeyword(), QString("class")); + found = true; + break; + } + } + QVERIFY2(found, "Root struct should exist after JSON round-trip"); + } + + void testClassKeywordDefaultsToStruct() { + NodeTree tree; + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + // classKeyword left empty + tree.addNode(root); + + QJsonObject json = tree.toJson(); + NodeTree tree2 = NodeTree::fromJson(json); + + for (const auto& n : tree2.nodes) { + if (n.kind == NodeKind::Struct) { + QVERIFY(n.classKeyword.isEmpty()); + QCOMPARE(n.resolvedClassKeyword(), QString("struct")); + break; + } + } + } + + void testComputeStructAlignment() { + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // Int32 has alignment 4 + Node f1; + f1.kind = NodeKind::Int32; + f1.name = "a"; + f1.parentId = rootId; + f1.offset = 0; + tree.addNode(f1); + + QCOMPARE(tree.computeStructAlignment(rootId), 4); + + // Add Hex64 (alignment 8) — max should become 8 + Node f2; + f2.kind = NodeKind::Hex64; + f2.name = "b"; + f2.parentId = rootId; + f2.offset = 8; + tree.addNode(f2); + + QCOMPARE(tree.computeStructAlignment(rootId), 8); + } + + void testComputeStructAlignmentEmpty() { + NodeTree tree; + Node root; + root.kind = NodeKind::Struct; + root.name = "Empty"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // Empty struct → alignment 1 + QCOMPARE(tree.computeStructAlignment(rootId), 1); + } + + void testCommandRow2AlignasSpan() { + // Test span detection for alignas(N) in CommandRow2 text + QString text = "struct MyClass alignas(8)"; + ColumnSpan span = commandRow2AlignasSpan(text); + QVERIFY(span.valid); + QVERIFY(span.start >= 0); + QVERIFY(span.end > span.start); + + QString spanText = text.mid(span.start, span.end - span.start); + QCOMPARE(spanText, QString("alignas(8)")); + } + + void testCommandRow2AlignasSpanNoMatch() { + // Text without alignas should return invalid span + QString text = "struct MyClass"; + ColumnSpan span = commandRow2AlignasSpan(text); + QVERIFY(!span.valid); + } + + void testCommandRow2NameSpanStopsBeforeAlignas() { + // Name span should NOT include the alignas part + QString text = "struct MyClass alignas(4)"; + ColumnSpan nameSpan = commandRow2NameSpan(text); + QVERIFY(nameSpan.valid); + + QString nameText = text.mid(nameSpan.start, nameSpan.end - nameSpan.start); + QVERIFY2(!nameText.contains("alignas"), + qPrintable("Name span should not include alignas: " + nameText)); + QVERIFY2(nameText.trimmed() == "MyClass", + qPrintable("Name span should be 'MyClass', got: '" + nameText.trimmed() + "'")); + } + void testTextIsNonEmpty() { // Verify composed text is actually generated (not empty) NodeTree tree; diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp index 8491d93..d5814e3 100644 --- a/tests/test_editor.cpp +++ b/tests/test_editor.cpp @@ -3,13 +3,44 @@ #include #include #include +#include #include #include +#include #include "editor.h" #include "core.h" using namespace rcx; +// ── Cursor test helpers ── + +static Qt::CursorShape viewportCursor(RcxEditor* editor) { + return editor->scintilla()->viewport()->cursor().shape(); +} + +static QPoint colToViewport(QsciScintilla* sci, int line, int col) { + long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN, + (unsigned long)line, (long)col); + int x = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION, 0, pos); + int y = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION, 0, pos); + return QPoint(x, y); +} + +static void sendMouseMove(QWidget* viewport, const QPoint& pos) { + QMouseEvent move(QEvent::MouseMove, QPointF(pos), QPointF(pos), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(viewport, &move); +} + +static void sendLeftClick(QWidget* viewport, const QPoint& pos) { + QMouseEvent press(QEvent::MouseButtonPress, QPointF(pos), QPointF(pos), + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(viewport, &press); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(pos), QPointF(pos), + Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(viewport, &release); +} + // 0x7D0 bytes of PEB-like data with recognizable values at key offsets static BufferProvider makeTestProvider() { QByteArray data(0x7D0, '\0'); @@ -363,7 +394,7 @@ private slots: // ── Test: inline edit lifecycle (begin → commit → re-edit) ── void testInlineEditReEntry() { - // Move cursor to line 2 (first field inside struct; line 0=CommandRow, 1=header) + // Move cursor to line 2 (first field; line 0=CommandRow, 1=CommandRow2, root header suppressed) m_editor->scintilla()->setCursorPosition(2, 0); // Should not be editing @@ -470,19 +501,36 @@ private slots: void testHeaderLineEdit() { m_editor->applyDocument(m_result); - // Line 1 should be the struct header (line 0 is CommandRow) - const LineMeta* lm = m_editor->metaForLine(1); + // Root header is suppressed; find a nested struct header (e.g. CSDVersion) + int headerLine = -1; + for (int i = 0; i < m_result.meta.size(); i++) { + if (m_result.meta[i].lineKind == LineKind::Header && + m_result.meta[i].foldHead) { + headerLine = i; + break; + } + } + QVERIFY2(headerLine >= 0, "Should have a nested struct header"); + + const LineMeta* lm = m_editor->metaForLine(headerLine); QVERIFY(lm); QCOMPARE(lm->lineKind, LineKind::Header); - // Type edit on header should succeed (has typename _PEB64) - bool ok = m_editor->beginInlineEdit(EditTarget::Type, 1); + // Scroll to header line to ensure visibility + m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_ENSUREVISIBLE, (unsigned long)headerLine); + m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_GOTOLINE, (unsigned long)headerLine); + QApplication::processEvents(); + + // Type edit on header should succeed + bool ok = m_editor->beginInlineEdit(EditTarget::Type, headerLine); QVERIFY(ok); QVERIFY(m_editor->isEditing()); m_editor->cancelInlineEdit(); // Name edit on header should succeed - ok = m_editor->beginInlineEdit(EditTarget::Name, 1); + ok = m_editor->beginInlineEdit(EditTarget::Name, headerLine); QVERIFY(ok); QVERIFY(m_editor->isEditing()); m_editor->cancelInlineEdit(); @@ -617,7 +665,7 @@ private slots: void testColumnSpanHitTest() { m_editor->applyDocument(m_result); - // Line 2 is a field line (UInt8), verify spans are valid (line 0=CommandRow, 1=header) + // Line 2 is a field line (UInt8), verify spans are valid (line 0=CommandRow, 1=CommandRow2) const LineMeta* lm = m_editor->metaForLine(2); QVERIFY(lm); QCOMPARE(lm->lineKind, LineKind::Field); @@ -664,7 +712,7 @@ private slots: void testSelectedNodeIndices() { m_editor->applyDocument(m_result); - // Put cursor on first field line (line 2; 0=CommandRow, 1=header) + // Put cursor on first field line (line 2; 0=CommandRow, 1=CommandRow2, root header suppressed) m_editor->scintilla()->setCursorPosition(2, 0); QSet sel = m_editor->selectedNodeIndices(); QCOMPARE(sel.size(), 1); @@ -675,7 +723,7 @@ private slots: QVERIFY(sel.contains(lm->nodeIdx)); } - // ── Test: header line no longer contains "// base:" ── + // ── Test: composed text does not contain "// base:" (moved to cmd bar) ── void testBaseAddressDisplay() { NodeTree tree = makeTestTree(); tree.baseAddress = 0x10; @@ -684,27 +732,14 @@ private slots: m_editor->applyDocument(result); - // Line 1 should be the struct header (line 0 is CommandRow) - const LineMeta* lm = m_editor->metaForLine(1); + // Root header is suppressed; verify no "// base:" anywhere in output + QVERIFY2(!result.text.contains("// base:"), + "Composed text should not contain '// base:' (consolidated into cmd bar)"); + + // Line 2 should be the first field (root header suppressed) + const LineMeta* lm = m_editor->metaForLine(2); QVERIFY(lm); - QCOMPARE(lm->lineKind, LineKind::Header); - QVERIFY(lm->isRootHeader); - - // Get header line text — should NOT contain "// base:" (consolidated into cmd bar) - QString lineText; - int len = (int)m_editor->scintilla()->SendScintilla( - QsciScintillaBase::SCI_LINELENGTH, (unsigned long)1); - if (len > 0) { - QByteArray buf(len + 1, '\0'); - m_editor->scintilla()->SendScintilla( - QsciScintillaBase::SCI_GETLINE, (unsigned long)1, (void*)buf.data()); - lineText = QString::fromUtf8(buf.constData(), len).trimmed(); - } - - QVERIFY2(!lineText.contains("// base:"), - qPrintable("Header should no longer contain '// base:', got: " + lineText)); - QVERIFY2(lineText.contains("struct"), - qPrintable("Header should contain 'struct', got: " + lineText)); + QCOMPARE(lm->lineKind, LineKind::Field); m_editor->applyDocument(m_result); } @@ -817,7 +852,7 @@ private slots: void testValueEditCommitUpdatesSignal() { m_editor->applyDocument(m_result); - // Line 2 = first UInt8 field (InheritedAddressSpace) + // Line 2 = first UInt8 field (InheritedAddressSpace, root header suppressed) const LineMeta* lm = m_editor->metaForLine(2); QVERIFY(lm); QCOMPARE(lm->lineKind, LineKind::Field); @@ -878,6 +913,192 @@ private slots: m_editor->cancelInlineEdit(); m_editor->applyDocument(m_result); } + + // ── Test: cursor stays Arrow after left-click on a node ── + void testCursorAfterLeftClick() { + m_editor->applyDocument(m_result); + + // Click on a field line at the indent area (col 0 — not over editable text) + QPoint clickPos = colToViewport(m_editor->scintilla(), 2, 0); + sendLeftClick(m_editor->scintilla()->viewport(), clickPos); + QApplication::processEvents(); + + // Cursor must be Arrow — QScintilla must NOT have set it to IBeam + QCOMPARE(viewportCursor(m_editor), Qt::ArrowCursor); + QVERIFY(!m_editor->isEditing()); + } + + // ── Test: cursor is IBeam only over trimmed name text, Arrow over padding ── + void testCursorShapeOverText() { + m_editor->applyDocument(m_result); + + // Line 2 is a field (UInt8 InheritedAddressSpace) + const LineMeta* lm = m_editor->metaForLine(2); + QVERIFY(lm); + + // Get the name span (padded to kColName width) + ColumnSpan ns = RcxEditor::nameSpan(*lm, lm->effectiveTypeW, lm->effectiveNameW); + QVERIFY(ns.valid); + + // Move mouse to the start of the name span (should be over text) + QPoint textPos = colToViewport(m_editor->scintilla(), 2, ns.start + 1); + sendMouseMove(m_editor->scintilla()->viewport(), textPos); + QApplication::processEvents(); + QCOMPARE(viewportCursor(m_editor), Qt::IBeamCursor); + + // Move mouse to far padding area (past end of text, within padded span) + // The padded span ends at ns.end but the trimmed text is shorter + QPoint padPos = colToViewport(m_editor->scintilla(), 2, ns.end - 1); + sendMouseMove(m_editor->scintilla()->viewport(), padPos); + QApplication::processEvents(); + // Should be Arrow (padding whitespace, not actual text) + QCOMPARE(viewportCursor(m_editor), Qt::ArrowCursor); + } + + // ── Test: cursor is PointingHand over type column text ── + void testCursorShapeOverType() { + m_editor->applyDocument(m_result); + + const LineMeta* lm = m_editor->metaForLine(2); + QVERIFY(lm); + + // Type span starts after the fold column + indent + ColumnSpan ts = RcxEditor::typeSpan(*lm, lm->effectiveTypeW); + QVERIFY(ts.valid); + + // Move to start of type text (e.g. "uint8_t") + QPoint typePos = colToViewport(m_editor->scintilla(), 2, ts.start + 1); + sendMouseMove(m_editor->scintilla()->viewport(), typePos); + QApplication::processEvents(); + QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor); + } + + // ── Test: cursor is PointingHand over fold column ── + void testCursorShapeInFoldColumn() { + m_editor->applyDocument(m_result); + QApplication::processEvents(); + + // Root header (line 2) has fold suppressed; find a nested struct with foldHead + int foldLine = -1; + for (int i = 0; i < m_result.meta.size(); i++) { + if (m_result.meta[i].foldHead && m_result.meta[i].lineKind == LineKind::Header) { + foldLine = i; + break; + } + } + QVERIFY2(foldLine >= 0, "Should have at least one foldable struct header"); + + const LineMeta* lm = m_editor->metaForLine(foldLine); + QVERIFY(lm); + QVERIFY(lm->foldHead); + + // Scroll to ensure the fold line is visible + m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_ENSUREVISIBLE, (unsigned long)foldLine); + m_editor->scintilla()->SendScintilla( + QsciScintillaBase::SCI_GOTOLINE, (unsigned long)foldLine); + QApplication::processEvents(); + + // Fold indicator is always at cols 0-2 (kFoldCol=3), regardless of depth + QPoint foldPos = colToViewport(m_editor->scintilla(), foldLine, 1); + QVERIFY2(foldPos.y() > 0, qPrintable(QString("Fold line %1 should be visible, got y=%2") + .arg(foldLine).arg(foldPos.y()))); + sendMouseMove(m_editor->scintilla()->viewport(), foldPos); + QApplication::processEvents(); + QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor); + } + + // ── Test: no IBeam after click then mouse-move to non-editable area ── + void testNoIBeamAfterClickThenMove() { + m_editor->applyDocument(m_result); + + // Click on a field to select the node + const LineMeta* lm = m_editor->metaForLine(2); + QVERIFY(lm); + ColumnSpan ns = RcxEditor::nameSpan(*lm, lm->effectiveTypeW, lm->effectiveNameW); + QVERIFY(ns.valid); + + // Click in the name area (selects the node) + QPoint clickPos = colToViewport(m_editor->scintilla(), 2, ns.start + 1); + sendLeftClick(m_editor->scintilla()->viewport(), clickPos); + QApplication::processEvents(); + + // Now move mouse to col 0 (indent area — non-editable) + QPoint emptyPos = colToViewport(m_editor->scintilla(), 2, 0); + sendMouseMove(m_editor->scintilla()->viewport(), emptyPos); + QApplication::processEvents(); + + // Must be Arrow, NOT IBeam (QScintilla must not have leaked its cursor state) + QCOMPARE(viewportCursor(m_editor), Qt::ArrowCursor); + QVERIFY(!m_editor->isEditing()); + } + + // ── Test: CommandRow2 exists at line 1 ── + void testCommandRow2Exists() { + m_editor->applyDocument(m_result); + + // Line 1 should be CommandRow2 + const LineMeta* lm = m_editor->metaForLine(1); + QVERIFY(lm); + QCOMPARE(lm->lineKind, LineKind::CommandRow2); + QCOMPARE(lm->nodeId, kCommandRow2Id); + QCOMPARE(lm->nodeIdx, -1); + + // Type/Name/Value should be rejected on CommandRow2 + QVERIFY(!m_editor->beginInlineEdit(EditTarget::Type, 1)); + QVERIFY(!m_editor->beginInlineEdit(EditTarget::Name, 1)); + QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, 1)); + QVERIFY(!m_editor->isEditing()); + + // RootClassName should be allowed on CommandRow2 + m_editor->setCommandRow2Text(QStringLiteral("struct _PEB64")); + bool ok = m_editor->beginInlineEdit(EditTarget::RootClassName, 1); + QVERIFY2(ok, "RootClassName edit should be allowed on CommandRow2"); + QVERIFY(m_editor->isEditing()); + m_editor->cancelInlineEdit(); + } + + // ── Test: alignas span detection on CommandRow2 ── + void testAlignasSpanOnCommandRow2() { + m_editor->applyDocument(m_result); + + // Set CommandRow2 with alignas + m_editor->setCommandRow2Text(QStringLiteral("struct _PEB64 alignas(8)")); + + // Line 1 is CommandRow2 + const LineMeta* lm = m_editor->metaForLine(1); + QVERIFY(lm); + QCOMPARE(lm->lineKind, LineKind::CommandRow2); + + // Alignas IS allowed as inline edit (picker-based) + QVERIFY(m_editor->beginInlineEdit(EditTarget::Alignas, 1)); + QVERIFY(m_editor->isEditing()); + m_editor->cancelInlineEdit(); + + m_editor->applyDocument(m_result); + } + + // ── Test: root header/footer are suppressed (CommandRow2 replaces them) ── + void testRootFoldSuppressed() { + m_editor->applyDocument(m_result); + + // Root struct header is completely suppressed from output. + // Line 0 = CommandRow, Line 1 = CommandRow2, Line 2 = first field. + const LineMeta* lm2 = m_editor->metaForLine(2); + QVERIFY(lm2); + QCOMPARE(lm2->lineKind, LineKind::Field); + + // Verify no root header exists anywhere in the output + bool foundRootHeader = false; + for (int i = 0; i < m_result.meta.size(); i++) { + if (m_result.meta[i].isRootHeader) { + foundRootHeader = true; + break; + } + } + QVERIFY2(!foundRootHeader, + "Root header should be suppressed from compose output"); + } }; QTEST_MAIN(TestEditor)