Fix 13 logic bugs and UI issues across editor, controller, and core

Round 1: Fix updateCommandRow offset, structTypeName undo, changeNodeKind
macro, shift-click kCommandRowId leak, type filter byte-vs-column bug.
Round 2: Move kFooterIdBit to core.h, add RcxEditor destructor for cursor
cleanup, defer refresh during batch ops, use newline separator in type
picker, narrow selection on double-click edit, clear hover on keyboard
scroll, guard 0x prefix from deletion, cap array count at 100k.
This commit is contained in:
DreamTeam2026
2026-02-06 12:57:01 -07:00
committed by sysadmin
parent e36d1591ba
commit 6852e0915e
15 changed files with 2221 additions and 130 deletions

View File

@@ -48,10 +48,6 @@ file(WRITE ${_combine_script} "
set(_out \"${CMAKE_BINARY_DIR}/h_cpp_combined.txt\")
file(WRITE \${_out} \"\")
foreach(_f
\"${CMAKE_SOURCE_DIR}/src/providers/provider.h\"
\"${CMAKE_SOURCE_DIR}/src/providers/buffer_provider.h\"
\"${CMAKE_SOURCE_DIR}/src/providers/null_provider.h\"
\"${CMAKE_SOURCE_DIR}/src/providers/process_provider.h\"
\"${CMAKE_SOURCE_DIR}/src/core.h\"
\"${CMAKE_SOURCE_DIR}/src/editor.h\"
\"${CMAKE_SOURCE_DIR}/src/editor.cpp\"
@@ -117,4 +113,13 @@ if(BUILD_TESTING)
target_link_libraries(test_provider_getSymbol PRIVATE psapi)
endif()
add_test(NAME test_provider_getSymbol COMMAND test_provider_getSymbol)
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)
target_include_directories(test_controller PRIVATE src)
target_link_libraries(test_controller PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Test
QScintilla::QScintilla dbghelp psapi)
add_test(NAME test_controller COMMAND test_controller)
endif()

View File

@@ -1,6 +1,5 @@
MIT License
Copyright (c) 2025 IChooChoose
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -65,6 +65,14 @@ uint32_t computeMarkers(const Node& node, const Provider& /*prov*/,
return mask;
}
static QString resolvePointerTarget(const NodeTree& tree, uint64_t refId) {
if (refId == 0) return {};
int refIdx = tree.indexOfId(refId);
if (refIdx < 0) return {};
const Node& ref = tree.nodes[refIdx];
return ref.structTypeName.isEmpty() ? ref.name : ref.structTypeName;
}
static inline uint64_t ptrToProviderAddr(const NodeTree& tree, uint64_t ptr) {
if (tree.baseAddress && ptr >= tree.baseAddress) return ptr - tree.baseAddress;
return ptr;
@@ -113,6 +121,14 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
numLines = linesForKind(node.kind);
}
// Resolve pointer target name for display
QString ptrTypeOverride;
QString ptrTargetName;
if (node.kind == NodeKind::Pointer32 || node.kind == NodeKind::Pointer64) {
ptrTargetName = resolvePointerTarget(tree, node.refId);
ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName);
}
for (int sub = 0; sub < numLines; sub++) {
bool isCont = (sub > 0);
@@ -129,9 +145,10 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
lm.foldLevel = computeFoldLevel(depth, false);
lm.effectiveTypeW = typeW;
lm.effectiveNameW = nameW;
lm.pointerTargetName = ptrTargetName;
QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub,
/*comment=*/{}, typeW, nameW);
/*comment=*/{}, typeW, nameW, ptrTypeOverride);
state.emitLine(lineText, lm);
}
}
@@ -269,15 +286,19 @@ void composeNode(ComposeState& state, const NodeTree& tree,
int typeW = state.effectiveTypeW(scopeId);
int nameW = state.effectiveNameW(scopeId);
// Pointer deref expansion
// Pointer deref expansion — single fold header merges pointer + struct header
if ((node.kind == NodeKind::Pointer32 || node.kind == NodeKind::Pointer64)
&& node.refId != 0) {
QString ptrTargetName = resolvePointerTarget(tree, node.refId);
QString ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName);
// Emit merged fold header: "ptr64<Type> Name {" (expanded) or "ptr64<Type> Name -> val" (collapsed)
{
LineMeta lm;
lm.nodeIdx = nodeIdx;
lm.nodeId = node.id;
lm.depth = depth;
lm.lineKind = LineKind::Field;
lm.lineKind = node.collapsed ? LineKind::Field : LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(absAddr, false);
lm.nodeKind = node.kind;
lm.foldHead = true;
@@ -286,8 +307,12 @@ void composeNode(ComposeState& state, const NodeTree& tree,
lm.markerMask = computeMarkers(node, prov, absAddr, false, depth);
lm.effectiveTypeW = typeW;
lm.effectiveNameW = nameW;
state.emitLine(fmt::fmtNodeLine(node, prov, absAddr, depth, 0, {}, typeW, nameW), lm);
lm.pointerTargetName = ptrTargetName;
state.emitLine(fmt::fmtPointerHeader(node, depth, node.collapsed,
prov, absAddr, ptrTypeOverride,
typeW, nameW), lm);
}
if (!node.collapsed) {
int sz = node.byteSize();
if (prov.isValid() && sz > 0 && prov.isReadable(absAddr, sz)) {
@@ -302,13 +327,31 @@ 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)
// isArrayChild=true skips header/footer, emits children only
// depth (not depth+1): pointer header replaces struct header,
// so children should be at depth+1, not depth+2
composeParent(state, tree, prov, refIdx,
depth + 1, pBase, ref.id);
depth, pBase, ref.id,
/*isArrayChild=*/true);
}
state.ptrVisiting.remove(key);
}
}
}
// Footer for pointer fold
{
LineMeta lm;
lm.nodeIdx = nodeIdx;
lm.nodeId = node.id;
lm.depth = depth;
lm.lineKind = LineKind::Footer;
lm.nodeKind = node.kind;
lm.offsetText.clear();
lm.foldLevel = computeFoldLevel(depth, false);
lm.markerMask = 0;
state.emitLine(fmt::indent(depth) + QStringLiteral("}"), lm);
}
}
return;
}
@@ -334,21 +377,22 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) {
for (int i = 0; i < tree.nodes.size(); i++)
state.absOffsets[i] = tree.computeOffset(i);
// Helper: compute the display type string for a node (for width calculation)
auto nodeTypeName = [&](const Node& n) -> QString {
if (n.kind == NodeKind::Array)
return fmt::arrayTypeName(n.elementKind, n.arrayLen);
if (n.kind == NodeKind::Struct)
return fmt::structTypeName(n);
if (n.kind == NodeKind::Pointer32 || n.kind == NodeKind::Pointer64)
return fmt::pointerTypeName(n.kind, resolvePointerTarget(tree, n.refId));
return fmt::typeNameRaw(n.kind);
};
// Compute effective type column width from longest type name
// Include struct/array headers which use "struct TypeName" or "type[count]" format
int maxTypeLen = kMinTypeW;
for (const Node& node : tree.nodes) {
QString typeName;
if (node.kind == NodeKind::Array) {
// Array type: "int32_t[10]", "char[64]", etc.
typeName = fmt::arrayTypeName(node.elementKind, node.arrayLen);
} else if (node.kind == NodeKind::Struct) {
// Struct type: "struct TypeName" or "struct"
typeName = fmt::structTypeName(node);
} else {
typeName = fmt::typeNameRaw(node.kind);
}
maxTypeLen = qMax(maxTypeLen, (int)typeName.size());
maxTypeLen = qMax(maxTypeLen, (int)nodeTypeName(node).size());
}
state.typeW = qBound(kMinTypeW, maxTypeLen, kMaxTypeW);
@@ -373,16 +417,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) {
for (int childIdx : state.childMap.value(container.id)) {
const Node& child = tree.nodes[childIdx];
// Type width - include struct/array headers too (they now use columnar layout)
QString childTypeName;
if (child.kind == NodeKind::Array)
childTypeName = fmt::arrayTypeName(child.elementKind, child.arrayLen);
else if (child.kind == NodeKind::Struct)
childTypeName = fmt::structTypeName(child);
else
childTypeName = fmt::typeNameRaw(child.kind);
scopeMaxType = qMax(scopeMaxType, (int)childTypeName.size());
scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size());
// Name width (skip hex/padding, but include containers)
if (!isHexPreview(child.kind)) {
@@ -401,16 +436,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) {
int rootMaxName = kMinNameW;
for (int childIdx : state.childMap.value(0)) {
const Node& child = tree.nodes[childIdx];
// Type width - include struct/array headers
QString childTypeName;
if (child.kind == NodeKind::Array)
childTypeName = fmt::arrayTypeName(child.elementKind, child.arrayLen);
else if (child.kind == NodeKind::Struct)
childTypeName = fmt::structTypeName(child);
else
childTypeName = fmt::typeNameRaw(child.kind);
rootMaxType = qMax(rootMaxType, (int)childTypeName.size());
rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size());
// Name width (skip hex/padding, include containers)
if (!isHexPreview(child.kind)) {

View File

@@ -20,9 +20,6 @@
namespace rcx {
// Footer selection ID: set high bit to distinguish footer-only selections from node selections
static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL;
static QString elide(QString s, int max) {
if (max <= 0) return {};
if (s.size() <= max) return s;
@@ -223,10 +220,14 @@ void RcxController::connectEditor(RcxEditor* editor) {
auto& node = m_doc->tree.nodes[nodeIdx];
if (node.kind != NodeKind::Struct)
changeNodeKind(nodeIdx, NodeKind::Struct);
// Set the struct type name via rename of structTypeName
int idx = m_doc->tree.indexOfId(node.id);
if (idx >= 0)
m_doc->tree.nodes[idx].structTypeName = text;
if (idx >= 0) {
QString oldTypeName = m_doc->tree.nodes[idx].structTypeName;
if (oldTypeName != text) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeStructTypeName{node.id, oldTypeName, text}));
}
}
}
}
}
@@ -305,6 +306,53 @@ void RcxController::connectEditor(RcxEditor* editor) {
}
break;
}
case EditTarget::ArrayElementType: {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) break;
const Node& node = m_doc->tree.nodes[nodeIdx];
if (node.kind != NodeKind::Array) break;
bool ok;
NodeKind elemKind = kindFromTypeName(text, &ok);
if (ok && elemKind != node.elementKind) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id,
node.elementKind, elemKind,
node.arrayLen, node.arrayLen}));
}
break;
}
case EditTarget::ArrayElementCount: {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) break;
const Node& node = m_doc->tree.nodes[nodeIdx];
if (node.kind != NodeKind::Array) break;
bool ok;
int newLen = text.toInt(&ok);
if (ok && newLen > 0 && newLen <= 100000 && newLen != node.arrayLen) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id,
node.elementKind, node.elementKind,
node.arrayLen, newLen}));
}
break;
}
case EditTarget::PointerTarget: {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) break;
Node& node = m_doc->tree.nodes[nodeIdx];
if (node.kind != NodeKind::Pointer32 && node.kind != NodeKind::Pointer64) break;
// Find the struct with matching name or structTypeName
uint64_t newRefId = 0;
for (const auto& n : m_doc->tree.nodes) {
if (n.kind == NodeKind::Struct &&
(n.structTypeName == text || n.name == text)) {
newRefId = n.id;
break;
}
}
if (newRefId != node.refId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, node.refId, newRefId}));
}
break;
}
case EditTarget::ArrayIndex:
case EditTarget::ArrayCount:
// Array navigation removed - these cases are unreachable
@@ -367,6 +415,10 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
uint64_t parentId = node.parentId;
int baseOffset = node.offset + newSize;
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Change type"));
// Push type change with no offset adjustments
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeKind{node.id, node.kind, newKind, {}}));
@@ -386,6 +438,10 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
padOffset += padSize;
gap -= padSize;
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
if (!m_suppressRefresh) refresh();
} else {
// Same size or larger: adjust sibling offsets as before
int delta = newSize - oldSize;
@@ -551,10 +607,19 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
if (tree.nodes[idx].viewIndex >= tree.nodes[idx].arrayLen)
tree.nodes[idx].viewIndex = qMax(0, tree.nodes[idx].arrayLen - 1);
}
} else if constexpr (std::is_same_v<T, cmd::ChangePointerRef>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].refId = isUndo ? c.oldRefId : c.newRefId;
} else if constexpr (std::is_same_v<T, cmd::ChangeStructTypeName>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].structTypeName = isUndo ? c.oldName : c.newName;
}
}, command);
refresh();
if (!m_suppressRefresh)
refresh();
}
void RcxController::setNodeValue(int nodeIdx, int subLine, const QString& text,
@@ -745,12 +810,15 @@ void RcxController::batchRemoveNodes(const QVector<int>& nodeIndices) {
m_selIds.clear();
m_anchorLine = -1;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QString("Delete %1 nodes").arg(idSet.size()));
for (uint64_t id : idSet) {
int idx = m_doc->tree.indexOfId(id);
if (idx >= 0) removeNode(idx);
}
m_doc->undoStack.endMacro();
m_suppressRefresh = false;
refresh();
}
void RcxController::batchChangeKind(const QVector<int>& nodeIndices, NodeKind newKind) {
@@ -766,12 +834,15 @@ void RcxController::batchChangeKind(const QVector<int>& nodeIndices, NodeKind ne
m_selIds.clear();
m_anchorLine = -1;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QString("Change type of %1 nodes").arg(idSet.size()));
for (uint64_t id : idSet) {
int idx = m_doc->tree.indexOfId(id);
if (idx >= 0) changeNodeKind(idx, newKind);
}
m_doc->undoStack.endMacro();
m_suppressRefresh = false;
refresh();
}
void RcxController::handleNodeClick(RcxEditor* source, int line,
@@ -811,7 +882,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) m_selIds.insert(effectiveId(i, nid));
if (nid != 0 && nid != kCommandRowId) m_selIds.insert(effectiveId(i, nid));
}
}
} else { // Ctrl+Shift
@@ -823,7 +894,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) m_selIds.insert(effectiveId(i, nid));
if (nid != 0 && nid != kCommandRowId) m_selIds.insert(effectiveId(i, nid));
}
}
}
@@ -869,7 +940,7 @@ void RcxController::updateCommandRow() {
int idx = m_doc->tree.indexOfId(sid & ~kFooterIdBit);
if (idx >= 0) {
const auto& node = m_doc->tree.nodes[idx];
uint64_t addr = m_doc->tree.baseAddress + node.offset;
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(idx);
sym = m_doc->provider->getSymbol(addr);
}
}

View File

@@ -94,6 +94,7 @@ private:
ComposeResult m_lastResult;
QSet<uint64_t> m_selIds;
int m_anchorLine = -1;
bool m_suppressRefresh = false;
void connectEditor(RcxEditor* editor);
void handleMarginClick(RcxEditor* editor, int margin, int line, Qt::KeyboardModifiers mods);

View File

@@ -71,7 +71,7 @@ inline constexpr KindMeta kKindMeta[] = {
{NodeKind::Double, "Double", "double", 8, 1, 8, KF_None},
{NodeKind::Bool, "Bool", "bool", 1, 1, 1, KF_None},
{NodeKind::Pointer32, "Pointer32", "ptr32", 4, 1, 4, KF_None},
{NodeKind::Pointer64, "Pointer64", "void*", 8, 1, 8, KF_None},
{NodeKind::Pointer64, "Pointer64", "ptr64", 8, 1, 8, KF_None},
{NodeKind::Vec2, "Vec2", "Vec2", 8, 2, 4, KF_Vector},
{NodeKind::Vec3, "Vec3", "Vec3", 12, 3, 4, KF_Vector},
{NodeKind::Vec4, "Vec4", "Vec4", 16, 4, 4, KF_Vector},
@@ -378,6 +378,7 @@ enum class LineKind : uint8_t {
static constexpr uint64_t kCommandRowId = UINT64_MAX;
static constexpr int kCommandRowLine = 0;
static constexpr int kFirstDataLine = 1;
static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL;
struct LineMeta {
int nodeIdx = -1;
@@ -400,6 +401,7 @@ struct LineMeta {
uint32_t markerMask = 0;
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) {
@@ -437,12 +439,15 @@ namespace cmd {
struct ChangeArrayMeta { uint64_t nodeId;
NodeKind oldElementKind, newElementKind;
int oldArrayLen, newArrayLen; };
struct ChangePointerRef { uint64_t nodeId;
uint64_t oldRefId, newRefId; };
struct ChangeStructTypeName { uint64_t nodeId; QString oldName, newName; };
}
using Command = std::variant<
cmd::ChangeKind, cmd::Rename, cmd::Collapse,
cmd::Insert, cmd::Remove, cmd::ChangeBase, cmd::WriteBytes,
cmd::ChangeArrayMeta
cmd::ChangeArrayMeta, cmd::ChangePointerRef, cmd::ChangeStructTypeName
>;
// ── Column spans (for inline editing) ──
@@ -453,7 +458,8 @@ struct ColumnSpan {
bool valid = false;
};
enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount };
enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount,
ArrayElementType, ArrayElementCount, PointerTarget };
// Column layout constants (shared with format.cpp span computation)
inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line
@@ -555,6 +561,52 @@ inline ColumnSpan commandRowAddrSpan(const QString& lineText) {
return {start, end, 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"
inline ColumnSpan arrayElemTypeSpanFor(const LineMeta& lm, const QString& lineText) {
if (lm.lineKind != LineKind::Header || !lm.isArrayHeader) return {};
int ind = kFoldCol + lm.depth * 3;
// Find '[' in the type portion
int bracket = lineText.indexOf('[', ind);
if (bracket <= ind) return {};
return {ind, bracket, true};
}
inline ColumnSpan arrayElemCountSpanFor(const LineMeta& lm, const QString& lineText) {
if (lm.lineKind != LineKind::Header || !lm.isArrayHeader) return {};
int ind = kFoldCol + lm.depth * 3;
int openBracket = lineText.indexOf('[', ind);
int closeBracket = lineText.indexOf(']', openBracket);
if (openBracket < 0 || closeBracket < 0 || closeBracket <= openBracket + 1) return {};
return {openBracket + 1, closeBracket, true};
}
// ── Pointer kind/target spans (within type column of pointer fields) ──
// Line format: " ptr64<void> name -> 0x..."
// pointerKindSpan covers "ptr64" or "ptr32", pointerTargetSpan covers the target name inside <>
inline ColumnSpan pointerKindSpanFor(const LineMeta& lm, const QString& lineText) {
if ((lm.lineKind != LineKind::Field && lm.lineKind != LineKind::Header) || lm.isContinuation) return {};
if (lm.nodeKind != NodeKind::Pointer32 && lm.nodeKind != NodeKind::Pointer64) return {};
int ind = kFoldCol + lm.depth * 3;
// Find '<' in the type portion
int lt = lineText.indexOf('<', ind);
if (lt <= ind) return {};
return {ind, lt, true};
}
inline ColumnSpan pointerTargetSpanFor(const LineMeta& lm, const QString& lineText) {
if ((lm.lineKind != LineKind::Field && lm.lineKind != LineKind::Header) || lm.isContinuation) return {};
if (lm.nodeKind != NodeKind::Pointer32 && lm.nodeKind != NodeKind::Pointer64) return {};
int ind = kFoldCol + lm.depth * 3;
int lt = lineText.indexOf('<', ind);
int gt = lineText.indexOf('>', lt);
if (lt < 0 || gt < 0 || gt <= lt + 1) return {};
return {lt + 1, gt, true};
}
// ── Array navigation spans ──
// Line format: "uint32_t[16] name { <0/16>"
@@ -619,13 +671,18 @@ namespace fmt {
QString fmtPointer64(uint64_t v);
QString fmtNodeLine(const Node& node, const Provider& prov,
uint64_t addr, int depth, int subLine = 0,
const QString& comment = {}, int colType = kColType, int colName = kColName);
const QString& comment = {}, int colType = kColType, int colName = kColName,
const QString& typeOverride = {});
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation);
QString fmtStructHeader(const Node& node, int depth, bool collapsed, int colType = kColType, int colName = kColName);
QString fmtStructFooter(const Node& node, int depth, int totalSize = -1);
QString fmtArrayHeader(const Node& node, int depth, int viewIdx, bool collapsed, int colType = kColType, int colName = kColName);
QString structTypeName(const Node& node); // Full type string for struct headers
QString arrayTypeName(NodeKind elemKind, int count);
QString pointerTypeName(NodeKind kind, const QString& targetName);
QString fmtPointerHeader(const Node& node, int depth, bool collapsed,
const Provider& prov, uint64_t addr,
const QString& ptrTypeName, int colType = kColType, int colName = kColName);
QString validateBaseAddress(const QString& text);
QString indent(int depth);
QString readValue(const Node& node, const Provider& prov,

View File

@@ -28,9 +28,6 @@ static constexpr int IND_BASE_ADDR = 10; // Green color for base address
static constexpr int IND_HOVER_SPAN = 11; // Blue text on hover (link-like)
static constexpr int IND_CMD_PILL = 12; // Rounded chip behind command row spans
// Footer selection ID: set high bit to distinguish footer-only selections from node selections
static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL;
static QString g_fontName = "Consolas";
static QFont editorFont() {
@@ -80,7 +77,9 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
connect(m_sci, &QsciScintilla::userListActivated,
this, [this](int id, const QString& text) {
if (!m_editState.active) return;
if (id == 1 && m_editState.target == EditTarget::Type) {
if (id == 1 && (m_editState.target == EditTarget::Type
|| m_editState.target == EditTarget::ArrayElementType
|| m_editState.target == EditTarget::PointerTarget)) {
auto info = endInlineEdit();
emit inlineEditCommitted(info.nodeIdx, info.subLine, info.target, text);
}
@@ -94,14 +93,21 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
if (m_updatingComment) return; // Skip queuing during comment update
if (m_editState.target == EditTarget::Value)
QTimer::singleShot(0, this, &RcxEditor::validateEditLive);
if (m_editState.target == EditTarget::Type)
if (m_editState.target == EditTarget::Type || m_editState.target == EditTarget::ArrayElementType)
QTimer::singleShot(0, this, &RcxEditor::updateTypeListFilter);
if (m_editState.target == EditTarget::PointerTarget)
QTimer::singleShot(0, this, &RcxEditor::updatePointerTargetFilter);
});
connect(m_sci, &QsciScintilla::selectionChanged,
this, &RcxEditor::clampEditSelection);
}
RcxEditor::~RcxEditor() {
if (m_cursorOverridden)
QApplication::restoreOverrideCursor();
}
void RcxEditor::setupScintilla() {
m_sci->setFont(editorFont());
@@ -723,6 +729,10 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
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;
QString lineText = getLineText(m_sci, line);
int textLen = lineText.size();
@@ -739,13 +749,24 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
case EditTarget::ArrayIndex:
case EditTarget::ArrayCount:
break; // Array navigation removed
case EditTarget::ArrayElementType:
s = arrayElemTypeSpanFor(*lm, lineText); break;
case EditTarget::ArrayElementCount:
s = arrayElemCountSpanFor(*lm, lineText); break;
case EditTarget::PointerTarget:
s = pointerTargetSpanFor(*lm, lineText); break;
case EditTarget::Source: break;
}
// Fallback spans for header lines
if (!s.valid && t == EditTarget::Type) {
// For pointer fields, the full type span acts as "kind" span
// For array headers, fall back to the full type[count] span
s = arrayHeaderTypeSpan(*lm, lineText);
if (!s.valid)
s = headerTypeNameSpan(*lm, lineText);
if (!s.valid)
s = pointerKindSpanFor(*lm, lineText);
}
if (!s.valid && t == EditTarget::Name)
s = headerNameSpan(*lm, lineText);
@@ -829,6 +850,22 @@ static bool hitTestTarget(QsciScintilla* sci,
ColumnSpan ns = RcxEditor::nameSpan(lm, typeW, nameW);
ColumnSpan vs = RcxEditor::valueSpan(lm, textLen, typeW, nameW);
// Pointer fields/headers: check sub-spans within type column first
if (lm.nodeKind == NodeKind::Pointer32 || lm.nodeKind == NodeKind::Pointer64) {
ColumnSpan ptrTarget = pointerTargetSpanFor(lm, lineText);
ColumnSpan ptrKind = pointerKindSpanFor(lm, lineText);
if (inSpan(ptrTarget)) { outTarget = EditTarget::PointerTarget; outLine = line; return true; }
if (inSpan(ptrKind)) { outTarget = EditTarget::Type; outLine = line; return true; }
}
// Array headers: check element type and count sub-spans first
if (lm.isArrayHeader) {
ColumnSpan elemType = arrayElemTypeSpanFor(lm, lineText);
ColumnSpan elemCount = arrayElemCountSpanFor(lm, lineText);
if (inSpan(elemCount)) { outTarget = EditTarget::ArrayElementCount; outLine = line; return true; }
if (inSpan(elemType)) { outTarget = EditTarget::ArrayElementType; outLine = line; return true; }
}
// Fallback spans for header lines
if (!ts.valid) {
ts = arrayHeaderTypeSpan(lm, lineText);
@@ -843,6 +880,10 @@ static bool hitTestTarget(QsciScintilla* sci,
else if (inSpan(vs)) outTarget = EditTarget::Value;
else return false;
// Padding nodes: hex bytes are display-only, not editable
if (outTarget == EditTarget::Value && lm.nodeKind == NodeKind::Padding)
return false;
outLine = line;
return true;
}
@@ -852,7 +893,14 @@ static bool hitTestTarget(QsciScintilla* sci,
bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
if (obj == m_sci && event->type() == QEvent::KeyPress) {
auto* ke = static_cast<QKeyEvent*>(event);
return m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
if (!handled && !m_editState.active) {
// Clear hover on keyboard navigation (stale after scroll)
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
}
return handled;
}
if (obj == m_sci->viewport() && event->type() == QEvent::MouseButtonPress
&& m_editState.active) {
@@ -882,6 +930,9 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
case EditTarget::Source: raw = commandRowSrcSpan(lineText); break;
case EditTarget::ArrayIndex: raw = arrayIndexSpanFor(*lm, lineText); break;
case EditTarget::ArrayCount: raw = arrayCountSpanFor(*lm, lineText); break;
case EditTarget::ArrayElementType: raw = arrayElemTypeSpanFor(*lm, lineText); break;
case EditTarget::ArrayElementCount: raw = arrayElemCountSpanFor(*lm, lineText); break;
case EditTarget::PointerTarget: raw = pointerTargetSpanFor(*lm, lineText); break;
}
if (raw.valid && h.col >= raw.start && h.col < raw.end) {
// Within raw span but outside trimmed text → move cursor to end
@@ -1009,6 +1060,10 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
int line; EditTarget t;
if (hitTestTarget(m_sci, m_meta, me->pos(), line, t)) {
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)
emit nodeClicked(h.line, h.nodeId, Qt::NoModifier);
return beginInlineEdit(t, line);
}
}
@@ -1079,12 +1134,15 @@ bool RcxEditor::handleNormalKey(QKeyEvent* ke) {
case Qt::Key_Enter:
return beginInlineEdit(EditTarget::Value);
case Qt::Key_Tab: {
EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value};
EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value,
EditTarget::ArrayElementType, EditTarget::ArrayElementCount,
EditTarget::PointerTarget};
constexpr int N = 6;
int start = 0;
for (int i = 0; i < 3; i++)
if (order[i] == m_lastTabTarget) { start = (i + 1) % 3; break; }
for (int i = 0; i < 3; i++) {
EditTarget t = order[(start + i) % 3];
for (int i = 0; i < N; i++)
if (order[i] == m_lastTabTarget) { start = (i + 1) % N; break; }
for (int i = 0; i < N; i++) {
EditTarget t = order[(start + i) % N];
if (beginInlineEdit(t)) { m_lastTabTarget = t; return true; }
}
return true;
@@ -1127,7 +1185,14 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) {
case Qt::Key_Backspace: {
int line, col;
m_sci->getCursorPosition(&line, &col);
if (col <= m_editState.spanStart) return true;
int minCol = m_editState.spanStart;
// Don't allow backing into "0x" prefix
if (m_editState.target == EditTarget::Value || m_editState.target == EditTarget::BaseAddress) {
QString lineText = getLineText(m_sci, m_editState.line);
if (lineText.mid(m_editState.spanStart, 2).startsWith(QStringLiteral("0x"), Qt::CaseInsensitive))
minCol = m_editState.spanStart + 2;
}
if (col <= minCol) return true;
return false;
}
case Qt::Key_Right: {
@@ -1136,9 +1201,17 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) {
if (col >= editEndCol()) return true; // block past end
return false;
}
case Qt::Key_Home:
m_sci->setCursorPosition(m_editState.line, m_editState.spanStart);
case Qt::Key_Home: {
int home = m_editState.spanStart;
// Skip "0x" prefix for hex values
if (m_editState.target == EditTarget::Value || m_editState.target == EditTarget::BaseAddress) {
QString lineText = getLineText(m_sci, m_editState.line);
if (lineText.mid(m_editState.spanStart, 2).startsWith(QStringLiteral("0x"), Qt::CaseInsensitive))
home = m_editState.spanStart + 2;
}
m_sci->setCursorPosition(m_editState.line, home);
return true;
}
case Qt::Key_End:
m_sci->setCursorPosition(m_editState.line, editEndCol());
return true;
@@ -1169,6 +1242,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) {
if (lm->nodeIdx < 0 && !(lm->lineKind == LineKind::CommandRow &&
(target == EditTarget::BaseAddress || target == EditTarget::Source)))
return false;
// Padding: reject value editing (display-only hex bytes)
if (target == EditTarget::Value && lm->nodeKind == NodeKind::Padding)
return false;
QString lineText;
NormalizedSpan norm;
@@ -1202,8 +1278,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, (long)0);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1);
m_sci->setReadOnly(false);
// Switch to I-beam for editing (skip for Type/Source which use popup pickers)
if (target != EditTarget::Type && target != EditTarget::Source) {
// 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 {
@@ -1233,10 +1310,12 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) {
if (target == EditTarget::Value)
setEditComment(QStringLiteral("Enter=Save Esc=Cancel"));
if (target == EditTarget::Type)
if (target == EditTarget::Type || target == EditTarget::ArrayElementType)
QTimer::singleShot(0, this, &RcxEditor::showTypeAutocomplete);
if (target == EditTarget::Source)
QTimer::singleShot(0, this, &RcxEditor::showSourcePicker);
if (target == EditTarget::PointerTarget)
QTimer::singleShot(0, this, &RcxEditor::showPointerTargetPicker);
return true;
}
@@ -1321,7 +1400,8 @@ void RcxEditor::cancelInlineEdit() {
// ── Type picker (user list) ──
void RcxEditor::showTypeAutocomplete() {
if (!m_editState.active || m_editState.target != EditTarget::Type)
if (!m_editState.active ||
(m_editState.target != EditTarget::Type && m_editState.target != EditTarget::ArrayElementType))
return;
// Replace original type with spaces (keeps layout, clears for typing)
int len = m_editState.original.size();
@@ -1338,7 +1418,8 @@ void RcxEditor::showTypeAutocomplete() {
}
void RcxEditor::showTypeListFiltered(const QString& filter) {
if (!m_editState.active || m_editState.target != EditTarget::Type)
if (!m_editState.active ||
(m_editState.target != EditTarget::Type && m_editState.target != EditTarget::ArrayElementType))
return;
// Combine native types with custom (struct) type names
@@ -1358,8 +1439,8 @@ void RcxEditor::showTypeListFiltered(const QString& filter) {
if (filtered.isEmpty()) return; // No matches - keep list hidden
// Show user list (id=1 for types) - selection handled by userListActivated signal
QByteArray list = filtered.join(' ').toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)' ');
QByteArray list = filtered.join('\n').toUtf8();
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()
@@ -1389,15 +1470,15 @@ void RcxEditor::showSourcePicker() {
}
void RcxEditor::updateTypeListFilter() {
if (!m_editState.active || m_editState.target != EditTarget::Type)
if (!m_editState.active ||
(m_editState.target != EditTarget::Type && m_editState.target != EditTarget::ArrayElementType))
return;
// Get currently typed text from line
QString lineText = getLineText(m_sci, m_editState.line);
long curPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS);
long lineStart = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)m_editState.line);
int col = (int)(curPos - lineStart);
int col = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETCOLUMN,
(unsigned long)curPos);
// Extract text from spanStart to cursor
int len = col - m_editState.spanStart;
@@ -1410,6 +1491,68 @@ void RcxEditor::updateTypeListFilter() {
showTypeListFiltered(typed);
}
// ── Pointer target picker ──
void RcxEditor::showPointerTargetPicker() {
if (!m_editState.active || m_editState.target != EditTarget::PointerTarget)
return;
// Replace original target with spaces (keeps layout, clears for typing)
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);
showPointerTargetListFiltered(QString());
}
void RcxEditor::showPointerTargetListFiltered(const QString& filter) {
if (!m_editState.active || m_editState.target != EditTarget::PointerTarget)
return;
// Build list: "void" + all struct type names
QStringList all;
all << QStringLiteral("void");
for (const QString& ct : m_customTypeNames) {
if (!all.contains(ct))
all << ct;
}
all.sort(Qt::CaseInsensitive);
// Ensure "void" is always first
all.removeAll(QStringLiteral("void"));
all.prepend(QStringLiteral("void"));
QStringList filtered;
for (const QString& t : all) {
if (filter.isEmpty() || t.startsWith(filter, Qt::CaseInsensitive))
filtered << t;
}
if (filtered.isEmpty()) return;
QByteArray list = filtered.join('\n').toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n');
m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW,
(uintptr_t)1, list.constData());
}
void RcxEditor::updatePointerTargetFilter() {
if (!m_editState.active || m_editState.target != EditTarget::PointerTarget)
return;
QString lineText = getLineText(m_sci, m_editState.line);
long curPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS);
int col = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETCOLUMN,
(unsigned long)curPos);
int len = col - m_editState.spanStart;
if (len <= 0) {
showPointerTargetListFiltered(QString());
return;
}
QString typed = lineText.mid(m_editState.spanStart, len);
showPointerTargetListFiltered(typed);
}
// ── Editable-field text-color indicator ──
void RcxEditor::paintEditableSpans(int line) {
@@ -1425,7 +1568,9 @@ void RcxEditor::paintEditableSpans(int line) {
return;
}
NormalizedSpan norm;
for (EditTarget t : {EditTarget::Type, EditTarget::Name, EditTarget::Value}) {
for (EditTarget t : {EditTarget::Type, EditTarget::Name, EditTarget::Value,
EditTarget::ArrayElementType, EditTarget::ArrayElementCount,
EditTarget::PointerTarget}) {
if (resolvedSpanFor(line, t, norm))
fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end);
}
@@ -1479,11 +1624,10 @@ void RcxEditor::updateEditableIndicators(int line) {
// ── Hover cursor ──
void RcxEditor::applyHoverCursor() {
// Clear previous hover span indicator
if (m_hoverSpanLine >= 0) {
clearIndicatorLine(IND_HOVER_SPAN, m_hoverSpanLine);
m_hoverSpanLine = -1;
}
// Clear previous hover span indicators
for (int ln : m_hoverSpanLines)
clearIndicatorLine(IND_HOVER_SPAN, ln);
m_hoverSpanLines.clear();
// Edit mode handles its own cursor (I-beam)
if (m_editState.active)
@@ -1511,22 +1655,48 @@ void RcxEditor::applyHoverCursor() {
return;
}
auto h = hitTest(m_lastHoverPos);
int line; EditTarget t;
bool tokenHit = hitTestTarget(m_sci, m_meta, m_lastHoverPos, line, t);
// Apply hover span indicator (blue text like a link) for editable spans
if (tokenHit) {
// For hex preview nodes, check if cursor is in the data area (ASCII or hex bytes)
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);
}
}
// 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) {
NormalizedSpan span;
if (resolvedSpanFor(line, t, span)) {
fillIndicatorCols(IND_HOVER_SPAN, line, span.start, span.end);
m_hoverSpanLine = line;
m_hoverSpanLines.append(line);
}
}
// Also show pointer cursor for fold column on fold-head lines
bool interactive = tokenHit;
bool interactive = tokenHit || inHexDataArea;
if (!interactive) {
auto h = hitTest(m_lastHoverPos);
if (h.inFoldCol) interactive = true;
}

View File

@@ -13,6 +13,7 @@ class RcxEditor : public QWidget {
Q_OBJECT
public:
explicit RcxEditor(QWidget* parent = nullptr);
~RcxEditor() override;
void applyDocument(const ComposeResult& result);
@@ -71,7 +72,7 @@ private:
uint64_t m_hoveredNodeId = 0;
int m_hoveredLine = -1;
QSet<uint64_t> m_currentSelIds;
int m_hoverSpanLine = -1; // Line with hover span indicator
QVector<int> m_hoverSpanLines; // Lines with hover span indicators
// ── Drag selection ──
bool m_dragging = false;
bool m_dragStarted = false; // true once drag threshold exceeded
@@ -134,6 +135,9 @@ private:
void showSourcePicker();
void showTypeListFiltered(const QString& filter);
void updateTypeListFilter();
void showPointerTargetPicker();
void showPointerTargetListFiltered(const QString& filter);
void updatePointerTargetFilter();
void paintEditableSpans(int line);
void updateEditableIndicators(int line);
void applyHoverCursor();

View File

@@ -51,6 +51,14 @@ QString arrayTypeName(NodeKind elemKind, int count) {
return elem + QStringLiteral("[") + QString::number(count) + QStringLiteral("]");
}
// Pointer type string: "ptr64<void>" or "ptr64<StructName>"
QString pointerTypeName(NodeKind kind, const QString& targetName) {
auto* m = kindMeta(kind);
QString base = m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
QString target = targetName.isEmpty() ? QStringLiteral("void") : targetName;
return base + QStringLiteral("<") + target + QStringLiteral(">");
}
// ── Value formatting ──
static QString hexVal(uint64_t v) {
@@ -132,6 +140,22 @@ QString fmtArrayHeader(const Node& node, int depth, int /*viewIdx*/, bool collap
return ind + type + SEP + name + SEP + suffix;
}
// ── Pointer header (merged pointer + struct header) ──
QString fmtPointerHeader(const Node& node, int depth, bool collapsed,
const Provider& prov, uint64_t addr,
const QString& ptrTypeName, int colType, int colName) {
QString ind = indent(depth);
QString type = fit(ptrTypeName, colType);
QString name = fit(node.name, colName);
if (collapsed) {
// Collapsed: show pointer value instead of brace
QString val = fit(readValue(node, prov, addr, 0), COL_VALUE);
return ind + type + SEP + name + SEP + val;
}
return ind + type + SEP + name + SEP + QStringLiteral("{");
}
// ── Hex / ASCII preview ──
static inline bool isAsciiPrintable(uint8_t c) { return c >= 0x20 && c <= 0x7E; }
@@ -277,9 +301,10 @@ QString readValue(const Node& node, const Provider& prov,
QString fmtNodeLine(const Node& node, const Provider& prov,
uint64_t addr, int depth, int subLine,
const QString& comment, int colType, int colName) {
const QString& comment, int colType, int colName,
const QString& typeOverride) {
QString ind = indent(depth);
QString type = typeName(node.kind, colType);
QString type = typeOverride.isEmpty() ? typeName(node.kind, colType) : fit(typeOverride, colType);
QString name = fit(node.name, colName);
// Blank prefix for continuation lines (same width as type+sep+name+sep)
const int prefixW = colType + colName + 2 * kSepWidth;

View File

@@ -271,10 +271,11 @@ void MainWindow::newFile() {
auto* doc = new RcxDocument(this);
// ══════════════════════════════════════════════════════════════════════════
// _PEB64 Demo — Process Environment Block (0x7D0 bytes)
// _PEB64 Demo — Process Environment Block + stub structs
// Buffer covers PEB (0x7D0) + _PEB_LDR_DATA (0x800) + _RTL_USER_PROCESS_PARAMETERS (0x900)
// ══════════════════════════════════════════════════════════════════════════
QByteArray pebData(0x7D0, '\0');
QByteArray pebData(0x940, '\0');
char* d = pebData.data();
auto w8 = [&](int off, uint8_t v) { d[off] = (char)v; };
@@ -286,8 +287,8 @@ void MainWindow::newFile() {
w8 (0x003, 0x04); // BitField
w64(0x008, 0xFFFFFFFFFFFFFFFFULL); // Mutant (-1)
w64(0x010, 0x00007FF6DE120000ULL); // ImageBaseAddress
w64(0x018, 0x00007FFE3B8B53C0ULL); // Ldr
w64(0x020, 0x000001A4C3E20F90ULL); // ProcessParameters
w64(0x018, 0x000000D87B5E5800ULL); // Ldr (baseAddress + 0x800)
w64(0x020, 0x000000D87B5E5900ULL); // ProcessParameters (baseAddress + 0x900)
w64(0x030, 0x000001A4C3D40000ULL); // ProcessHeap
w64(0x038, 0x00007FFE3B8D4260ULL); // FastPebLock
w32(0x050, 0x01); // CrossProcessFlags
@@ -329,6 +330,26 @@ void MainWindow::newFile() {
w64(0x398, 0x000000D87B5E5390ULL); // TppWorkerpList.Blink (self)
w64(0x7B8, 0x00007FFE38860000ULL); // LeapSecondData
// ── _PEB_LDR_DATA at offset 0x800 ──
w32(0x800, 0x48); // Length
w8 (0x804, 0x01); // Initialized
w64(0x808, 0x0000000000000000ULL); // SsHandle
w64(0x810, 0x000001A4C3D40100ULL); // InLoadOrderModuleList.Flink
w64(0x818, 0x000001A4C3D40200ULL); // InLoadOrderModuleList.Blink
w64(0x820, 0x000001A4C3D40110ULL); // InMemoryOrderModuleList.Flink
w64(0x828, 0x000001A4C3D40210ULL); // InMemoryOrderModuleList.Blink
// ── _RTL_USER_PROCESS_PARAMETERS at offset 0x900 ──
w32(0x900, 0x07B0); // MaximumLength
w32(0x904, 0x07B0); // Length
w32(0x908, 0x0001); // Flags (NORMALIZED)
w32(0x90C, 0x0000); // DebugFlags
w64(0x910, 0x0000000000000044ULL); // ConsoleHandle
w32(0x918, 0x0000); // ConsoleFlags
w64(0x920, 0x0000000000000008ULL); // StandardInput
w64(0x928, 0x000000000000000CULL); // StandardOutput
w64(0x930, 0x0000000000000010ULL); // StandardError
doc->loadData(pebData);
doc->tree.baseAddress = 0x000000D87B5E5000ULL;
@@ -359,7 +380,17 @@ void MainWindow::newFile() {
n.parentId = parent; n.offset = offset;
n.arrayLen = count; n.elementKind = elemKind;
n.collapsed = true;
doc->tree.addNode(n);
int idx = doc->tree.addNode(n);
uint64_t arrId = doc->tree.nodes[idx].id;
int elemSz = sizeForKind(elemKind);
if (elemSz > 0) {
for (int i = 0; i < count; i++) {
Node e; e.kind = elemKind;
e.name = QStringLiteral("[%1]").arg(i);
e.parentId = arrId; e.offset = i * elemSz;
doc->tree.addNode(e);
}
}
};
// Root struct (not collapsed so fields are visible on open)
@@ -375,8 +406,8 @@ void MainWindow::newFile() {
// 0x008 0x04F
addField(peb, 0x008, NodeKind::Pointer64, "Mutant");
addField(peb, 0x010, NodeKind::Pointer64, "ImageBaseAddress");
addField(peb, 0x018, NodeKind::Pointer64, "Ldr");
addField(peb, 0x020, NodeKind::Pointer64, "ProcessParameters");
uint64_t ldrPtrId = addField(peb, 0x018, NodeKind::Pointer64, "Ldr");
uint64_t ppPtrId = addField(peb, 0x020, NodeKind::Pointer64, "ProcessParameters");
addField(peb, 0x028, NodeKind::Pointer64, "SubSystemData");
addField(peb, 0x030, NodeKind::Pointer64, "ProcessHeap");
addField(peb, 0x038, NodeKind::Pointer64, "FastPebLock");
@@ -509,6 +540,45 @@ void MainWindow::newFile() {
addField(peb, 0x7C4, NodeKind::UInt32, "NtGlobalFlag2");
addField(peb, 0x7C8, NodeKind::UInt64, "ExtendedFeatureDisableMask");
// ── Stub structs for pointer deref demo ──
// _PEB_LDR_DATA (Ldr target)
uint64_t ldrData = addStruct(0, 0x800, "_PEB_LDR_DATA", "LdrData");
addField(ldrData, 0x00, NodeKind::UInt32, "Length");
addField(ldrData, 0x04, NodeKind::UInt8, "Initialized");
addPad (ldrData, 0x05, 3, "Pad");
addField(ldrData, 0x08, NodeKind::Pointer64, "SsHandle");
{
uint64_t le = addStruct(ldrData, 0x10, "LIST_ENTRY64", "InLoadOrderModuleList");
addField(le, 0, NodeKind::Pointer64, "Flink");
addField(le, 8, NodeKind::Pointer64, "Blink");
}
{
uint64_t le = addStruct(ldrData, 0x20, "LIST_ENTRY64", "InMemoryOrderModuleList");
addField(le, 0, NodeKind::Pointer64, "Flink");
addField(le, 8, NodeKind::Pointer64, "Blink");
}
// _RTL_USER_PROCESS_PARAMETERS (ProcessParameters target)
uint64_t procParams = addStruct(0, 0x900, "_RTL_USER_PROCESS_PARAMETERS", "ProcessParams");
addField(procParams, 0x00, NodeKind::UInt32, "MaximumLength");
addField(procParams, 0x04, NodeKind::UInt32, "Length");
addField(procParams, 0x08, NodeKind::UInt32, "Flags");
addField(procParams, 0x0C, NodeKind::UInt32, "DebugFlags");
addField(procParams, 0x10, NodeKind::Pointer64, "ConsoleHandle");
addField(procParams, 0x18, NodeKind::UInt32, "ConsoleFlags");
addPad (procParams, 0x1C, 4, "Pad");
addField(procParams, 0x20, NodeKind::Pointer64, "StandardInput");
addField(procParams, 0x28, NodeKind::Pointer64, "StandardOutput");
addField(procParams, 0x30, NodeKind::Pointer64, "StandardError");
// Wire up pointer refIds
{
int li = doc->tree.indexOfId(ldrPtrId);
if (li >= 0) doc->tree.nodes[li].refId = ldrData;
int pi = doc->tree.indexOfId(ppPtrId);
if (pi >= 0) doc->tree.nodes[pi].refId = procParams;
}
createTab(doc);
}

View File

@@ -35,6 +35,7 @@ public:
ProcessProvider& operator=(const ProcessProvider&) = delete;
int size() const override { return m_size; }
bool isReadable(uint64_t, int len) const override { return len >= 0; }
bool read(uint64_t addr, void* buf, int len) const override {
SIZE_T got = 0;

View File

@@ -42,7 +42,7 @@ public:
bool isValid() const { return size() > 0; }
bool isReadable(uint64_t addr, int len) const {
virtual bool isReadable(uint64_t addr, int len) const {
if (len <= 0) return (len == 0);
uint64_t ulen = (uint64_t)len;
return addr <= (uint64_t)size() && ulen <= (uint64_t)size() - addr;

File diff suppressed because it is too large Load Diff

454
tests/test_controller.cpp Normal file
View File

@@ -0,0 +1,454 @@
#include <QtTest/QTest>
#include <QtTest/QSignalSpy>
#include <QApplication>
#include <QSplitter>
#include <Qsci/qsciscintilla.h>
#include "controller.h"
#include "core.h"
using namespace rcx;
// Small tree: one root struct with a few typed fields at known offsets.
// Keeps tests fast and deterministic (no giant PEB tree).
static void buildSmallTree(NodeTree& tree) {
tree.baseAddress = 0x1000;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "TestStruct";
root.name = "root";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
auto field = [&](int off, NodeKind k, const char* name) {
Node n;
n.kind = k;
n.name = name;
n.parentId = rootId;
n.offset = off;
tree.addNode(n);
};
field(0, NodeKind::UInt32, "field_u32"); // 4 bytes
field(4, NodeKind::Float, "field_float"); // 4 bytes
field(8, NodeKind::UInt8, "field_u8"); // 1 byte
field(9, NodeKind::Padding, "pad0"); // 3 bytes padding
// Set padding arrayLen = 3 for 3-byte padding
tree.nodes.last().arrayLen = 3;
field(12, NodeKind::Hex32, "field_hex"); // 4 bytes
}
// 64-byte buffer with recognizable pattern
static QByteArray makeSmallBuffer() {
QByteArray data(64, '\0');
// field_u32 at offset 0 = 0xDEADBEEF
uint32_t v32 = 0xDEADBEEF;
memcpy(data.data() + 0, &v32, 4);
// field_float at offset 4 = 3.14f
float vf = 3.14f;
memcpy(data.data() + 4, &vf, 4);
// field_u8 at offset 8 = 0x42
data[8] = 0x42;
// pad0 at offset 9 = 0x00 0x00 0x00
// field_hex at offset 12 = 0xCAFEBABE
uint32_t vhex = 0xCAFEBABE;
memcpy(data.data() + 12, &vhex, 4);
return data;
}
class TestController : public QObject {
Q_OBJECT
private:
RcxDocument* m_doc = nullptr;
RcxController* m_ctrl = nullptr;
QSplitter* m_splitter = nullptr;
RcxEditor* m_editor = nullptr;
private slots:
void init() {
m_doc = new RcxDocument();
buildSmallTree(m_doc->tree);
m_doc->provider = std::make_unique<BufferProvider>(makeSmallBuffer());
m_splitter = new QSplitter();
// Pass nullptr as parent so controller is not auto-deleted with splitter
m_ctrl = new RcxController(m_doc, nullptr);
m_editor = m_ctrl->addSplitEditor(m_splitter);
m_splitter->resize(800, 600);
m_splitter->show();
QVERIFY(QTest::qWaitForWindowExposed(m_splitter));
QApplication::processEvents();
}
void cleanup() {
// Delete controller first (disconnects from editor signals)
delete m_ctrl;
m_ctrl = nullptr;
m_editor = nullptr; // owned by splitter
delete m_splitter;
m_splitter = nullptr;
delete m_doc;
m_doc = nullptr;
}
// ── Test: setNodeValue writes bytes to provider ──
void testSetNodeValueWritesData() {
// Find field_u32 (index 1, child of root at index 0)
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
// Verify original value in provider
uint64_t addr = m_doc->tree.computeOffset(idx);
QByteArray origBytes = m_doc->provider->readBytes(addr, 4);
uint32_t origVal;
memcpy(&origVal, origBytes.data(), 4);
QCOMPARE(origVal, (uint32_t)0xDEADBEEF);
// Write new value "42" (decimal)
m_ctrl->setNodeValue(idx, 0, "42");
QApplication::processEvents();
// Read back: should be 42 in little-endian
QByteArray newBytes = m_doc->provider->readBytes(addr, 4);
uint32_t newVal;
memcpy(&newVal, newBytes.data(), 4);
QCOMPARE(newVal, (uint32_t)42);
}
// ── Test: setNodeValue undo/redo restores data ──
void testSetNodeValueUndoRedo() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(idx);
// Original: 0xDEADBEEF
QByteArray orig = m_doc->provider->readBytes(addr, 4);
uint32_t origVal;
memcpy(&origVal, orig.data(), 4);
QCOMPARE(origVal, (uint32_t)0xDEADBEEF);
// Write new value
m_ctrl->setNodeValue(idx, 0, "99");
QApplication::processEvents();
uint32_t newVal;
QByteArray after = m_doc->provider->readBytes(addr, 4);
memcpy(&newVal, after.data(), 4);
QCOMPARE(newVal, (uint32_t)99);
// Undo → should restore original
m_doc->undoStack.undo();
QApplication::processEvents();
QByteArray undone = m_doc->provider->readBytes(addr, 4);
uint32_t undoneVal;
memcpy(&undoneVal, undone.data(), 4);
QCOMPARE(undoneVal, (uint32_t)0xDEADBEEF);
// Redo → should restore new value
m_doc->undoStack.redo();
QApplication::processEvents();
QByteArray redone = m_doc->provider->readBytes(addr, 4);
uint32_t redoneVal;
memcpy(&redoneVal, redone.data(), 4);
QCOMPARE(redoneVal, (uint32_t)99);
}
// ── Test: setNodeValue on Float field ──
void testSetNodeValueFloat() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_float") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(idx);
// Original: 3.14f
QByteArray orig = m_doc->provider->readBytes(addr, 4);
float origVal;
memcpy(&origVal, orig.data(), 4);
QVERIFY(qAbs(origVal - 3.14f) < 0.01f);
// Write "1.5"
m_ctrl->setNodeValue(idx, 0, "1.5");
QApplication::processEvents();
QByteArray after = m_doc->provider->readBytes(addr, 4);
float newVal;
memcpy(&newVal, after.data(), 4);
QCOMPARE(newVal, 1.5f);
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QByteArray undone = m_doc->provider->readBytes(addr, 4);
float undoneVal;
memcpy(&undoneVal, undone.data(), 4);
QVERIFY(qAbs(undoneVal - 3.14f) < 0.01f);
}
// ── Test: renameNode changes name and undo restores ──
void testRenameNode() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].name, QString("field_u32"));
m_ctrl->renameNode(idx, "myRenamedField");
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].name, QString("myRenamedField"));
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].name, QString("field_u32"));
// Redo
m_doc->undoStack.redo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].name, QString("myRenamedField"));
}
// ── Test: changeNodeKind changes type and undo restores ──
void testChangeNodeKind() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
m_ctrl->changeNodeKind(idx, NodeKind::Float);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::Float);
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
}
// ── Test: insertNode adds a node, removeNode removes it, undo restores ──
void testInsertAndRemoveNode() {
int origSize = m_doc->tree.nodes.size();
uint64_t rootId = m_doc->tree.nodes[0].id;
// Insert a new Hex64 at offset 16
m_ctrl->insertNode(rootId, 16, NodeKind::Hex64, "newHex");
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
// Find the inserted node
int newIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "newHex") { newIdx = i; break; }
}
QVERIFY(newIdx >= 0);
QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::Hex64);
QCOMPARE(m_doc->tree.nodes[newIdx].offset, 16);
// Remove it
m_ctrl->removeNode(newIdx);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize);
// Undo remove → node restored
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
// Find again
newIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "newHex") { newIdx = i; break; }
}
QVERIFY(newIdx >= 0);
}
// ── Test: Padding value edit is effectively blocked at controller level ──
void testPaddingValueEditIsBlocked() {
// Find the padding node
int padIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].kind == NodeKind::Padding) { padIdx = i; break; }
}
QVERIFY(padIdx >= 0);
uint64_t addr = m_doc->tree.computeOffset(padIdx);
// Read original data at padding offset
int padSize = m_doc->tree.nodes[padIdx].byteSize();
QByteArray origData = m_doc->provider->readBytes(addr, padSize);
// The context menu blocks Padding editing, so the controller's setNodeValue
// would only be called if the editing UI somehow allows it. But let's verify
// the editor correctly blocks it.
// Find padding line in composed output
ComposeResult result = m_doc->compose();
int paddingLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::Padding &&
result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
m_editor->applyDocument(result);
QApplication::processEvents();
// beginInlineEdit(Value) on Padding line must be rejected
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
// Data must be unchanged
QByteArray afterData = m_doc->provider->readBytes(addr, padSize);
QCOMPARE(afterData, origData);
}
// ── Test: setNodeValue with Hex32 (space-separated hex bytes) ──
void testSetNodeValueHex() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_hex") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(idx);
// Original: 0xCAFEBABE
QByteArray orig = m_doc->provider->readBytes(addr, 4);
uint32_t origVal;
memcpy(&origVal, orig.data(), 4);
QCOMPARE(origVal, (uint32_t)0xCAFEBABE);
// Write space-separated hex bytes "AA BB CC DD"
m_ctrl->setNodeValue(idx, 0, "AA BB CC DD");
QApplication::processEvents();
QByteArray after = m_doc->provider->readBytes(addr, 4);
QCOMPARE((uint8_t)after[0], (uint8_t)0xAA);
QCOMPARE((uint8_t)after[1], (uint8_t)0xBB);
QCOMPARE((uint8_t)after[2], (uint8_t)0xCC);
QCOMPARE((uint8_t)after[3], (uint8_t)0xDD);
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QByteArray undone = m_doc->provider->readBytes(addr, 4);
uint32_t undoneVal;
memcpy(&undoneVal, undone.data(), 4);
QCOMPARE(undoneVal, (uint32_t)0xCAFEBABE);
}
// ── Test: full inline edit round-trip (type in editor → commit → verify provider) ──
void testInlineEditRoundTrip() {
// Refresh to get composed output
m_ctrl->refresh();
QApplication::processEvents();
// Find field_u8 line (UInt8 at offset 8, value = 0x42 = 66)
ComposeResult result = m_doc->compose();
int fieldLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::UInt8 &&
result.meta[i].lineKind == LineKind::Field) {
fieldLine = i;
break;
}
}
QVERIFY(fieldLine >= 0);
m_editor->applyDocument(result);
QApplication::processEvents();
// Select this node so edit is allowed
uint64_t nodeId = result.meta[fieldLine].nodeId;
QSet<uint64_t> sel;
sel.insert(nodeId);
m_editor->applySelectionOverlay(sel);
QApplication::processEvents();
// Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, fieldLine);
QVERIFY2(ok, "Should be able to begin value edit on UInt8 field");
QVERIFY(m_editor->isEditing());
// UInt8 values display in hex (e.g., "0x42"). beginInlineEdit selects
// from after "0x" to end. Type "FF" to replace the hex digits.
for (QChar c : QString("FF")) {
QKeyEvent key(QEvent::KeyPress, 0, Qt::NoModifier, QString(c));
QApplication::sendEvent(m_editor->scintilla(), &key);
}
QApplication::processEvents();
// Commit
QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted);
QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier);
QApplication::sendEvent(m_editor->scintilla(), &enter);
QCOMPARE(spy.count(), 1);
QList<QVariant> args = spy.first();
int nodeIdx = args.at(0).toInt();
QString text = args.at(3).toString().trimmed();
// The committed text should contain "0xFF" (hex format for UInt8)
QVERIFY2(!text.isEmpty(), "Committed text should not be empty");
// Now simulate what controller does: setNodeValue
m_ctrl->setNodeValue(nodeIdx, 0, text);
QApplication::processEvents();
// Verify provider data changed
int u8Idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u8") { u8Idx = i; break; }
}
QVERIFY(u8Idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(u8Idx);
QByteArray bytes = m_doc->provider->readBytes(addr, 1);
QCOMPARE((uint8_t)bytes[0], (uint8_t)0xFF);
}
// ── Test: toggleCollapse + undo ──
void testToggleCollapse() {
// Root is index 0, a Struct node
QCOMPARE(m_doc->tree.nodes[0].kind, NodeKind::Struct);
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
m_ctrl->toggleCollapse(0);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, true);
m_ctrl->toggleCollapse(0);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
// Undo twice: uncollapse → collapse → original (false)
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, true);
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
}
};
QTEST_MAIN(TestController)
#include "test_controller.moc"

View File

@@ -748,6 +748,119 @@ private slots:
m_editor->applyDocument(m_result);
}
// ── Test: Padding line rejects value editing ──
void testPaddingLineRejectsValueEdit() {
m_editor->applyDocument(m_result);
// Find a Padding line in the composed output
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY2(paddingLine >= 0, "Should have at least one Padding line in test tree");
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
QCOMPARE(lm->nodeKind, NodeKind::Padding);
// Value edit on Padding MUST be rejected (the bug fix)
QVERIFY2(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine),
"Value edit should be rejected on Padding lines");
QVERIFY(!m_editor->isEditing());
// Name edit on Padding SHOULD succeed (ASCII preview column is editable)
bool ok = m_editor->beginInlineEdit(EditTarget::Name, paddingLine);
QVERIFY2(ok, "Name edit should be allowed on Padding lines (ASCII preview)");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
// Type edit on Padding SHOULD succeed
ok = m_editor->beginInlineEdit(EditTarget::Type, paddingLine);
QVERIFY2(ok, "Type edit should be allowed on Padding lines");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
QApplication::processEvents(); // flush deferred autocomplete timer
}
// ── Test: resolvedSpanFor rejects Value on Padding (defense-in-depth) ──
void testPaddingLineRejectsValueSpan() {
m_editor->applyDocument(m_result);
// Find a Padding line
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
// valueSpanFor returns valid (shared with Hex via KF_HexPreview)
ColumnSpan vs = RcxEditor::valueSpan(*lm, 200);
QVERIFY2(vs.valid, "valueSpanFor should return valid for Padding (shared HexPreview flag)");
// But beginInlineEdit should still reject it
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
}
// ── Test: value edit commit fires signal with typed text ──
void testValueEditCommitUpdatesSignal() {
m_editor->applyDocument(m_result);
// Line 2 = first UInt8 field (InheritedAddressSpace)
const LineMeta* lm = m_editor->metaForLine(2);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Field);
QVERIFY(lm->nodeKind != NodeKind::Padding);
// Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, 2);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Select all text in the edit span and type replacement
QKeyEvent home(QEvent::KeyPress, Qt::Key_Home, Qt::NoModifier);
QApplication::sendEvent(m_editor->scintilla(), &home);
QKeyEvent end(QEvent::KeyPress, Qt::Key_End, Qt::ShiftModifier);
QApplication::sendEvent(m_editor->scintilla(), &end);
// Type "42" to replace selected text
for (QChar c : QString("42")) {
QKeyEvent key(QEvent::KeyPress, 0, Qt::NoModifier, QString(c));
QApplication::sendEvent(m_editor->scintilla(), &key);
}
QApplication::processEvents();
// Commit with Enter
QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted);
QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier);
QApplication::sendEvent(m_editor->scintilla(), &enter);
QCOMPARE(spy.count(), 1);
QVERIFY(!m_editor->isEditing());
// Verify the committed text contains what was typed.
// UInt8 values display as hex (e.g., "0x042"), so the typed "42" gets
// concatenated with the existing "0x0" prefix → "0x042".
// The important check: the signal fired with non-empty text.
QList<QVariant> args = spy.first();
QString committedText = args.at(3).toString().trimmed();
QVERIFY2(!committedText.isEmpty(),
"Committed text should not be empty");
m_editor->applyDocument(m_result);
}
// ── Test: base address edit begins on CommandRow (line 0) ──
void testBaseAddressEditBegins() {
m_editor->applyDocument(m_result);