mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
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:
@@ -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()
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
65
src/core.h
65
src/core.h
@@ -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,
|
||||
|
||||
242
src/editor.cpp
242
src/editor.cpp
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
src/main.cpp
84
src/main.cpp
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
454
tests/test_controller.cpp
Normal 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"
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user