feat: inline hex byte and ASCII preview editors for hex nodes

Right-click context menu adds "Edit Hex Bytes" and "Edit ASCII" for
hex nodes (Hex8/16/32/64). Both are fixed-length overwrite-mode editors
with space-skipping, input validation, and IND_HEX_DIM indicator
preservation.
This commit is contained in:
IChooseYou
2026-03-11 16:01:37 -06:00
committed by IChooseYou
parent 3b7ed682ac
commit 3aeb1a80d5
3 changed files with 332 additions and 18 deletions

View File

@@ -1321,6 +1321,10 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].isStatic = isUndo ? c.oldVal : c.newVal;
} else if constexpr (std::is_same_v<T, cmd::ToggleRelative>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].isRelative = isUndo ? c.oldVal : c.newVal;
}
}, command);
@@ -2006,6 +2010,19 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
if (addedQuickConvert)
menu.addSeparator();
// ── Hex byte / ASCII inline editing ──
if (isHexNode(node.kind) && m_doc->provider->isWritable()) {
menu.addAction(icon("edit.svg"), "Edit He&x Bytes", [editor, line]() {
editor->setHexEditPending(true);
editor->beginInlineEdit(EditTarget::Value, line);
});
menu.addAction(icon("edit.svg"), "Edit &ASCII", [editor, line]() {
editor->setHexEditPending(true);
editor->beginInlineEdit(EditTarget::Name, line);
});
menu.addSeparator();
}
// ── Edit Value / Rename / Change Type ──
bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array
&& !isHexNode(node.kind)
@@ -2016,9 +2033,11 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
});
}
menu.addAction(icon("rename.svg"), "Re&name\tF2", [editor, line]() {
editor->beginInlineEdit(EditTarget::Name, line);
});
if (!isHexNode(node.kind)) {
menu.addAction(icon("rename.svg"), "Re&name\tF2", [editor, line]() {
editor->beginInlineEdit(EditTarget::Name, line);
});
}
menu.addAction("Change &Type\tT", [editor, line]() {
editor->beginInlineEdit(EditTarget::Type, line);
@@ -2088,6 +2107,21 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
hasConvert = true;
}
// Toggle relative pointer (RVA: target = base + value)
if (node.kind == NodeKind::Pointer64 || node.kind == NodeKind::Pointer32) {
QString label = node.isRelative
? QStringLiteral("Pointer is Absolute")
: QStringLiteral("Pointer is &Relative (base + value)");
convertMenu->addAction(label, [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
const Node& n = m_doc->tree.nodes[ni];
m_doc->undoStack.push(new RcxCommand(this,
cmd::ToggleRelative{n.id, n.isRelative, !n.isRelative}));
});
hasConvert = true;
}
// Split hex node into two half-sized hex nodes
if (node.kind == NodeKind::Hex64) {
convertMenu->addAction("Split to hex32+hex32", [this, nodeId]() {
@@ -3462,6 +3496,10 @@ void RcxController::collectPointerRanges(
: m_snapshotProv->readU64(ptrAddr);
if (ptrVal == 0 || ptrVal == UINT64_MAX) continue;
// Relative pointer (RVA): target = base + value
if (child.isRelative)
ptrVal += memBase;
uint64_t pBase = ptrVal;
collectPointerRanges(child.refId, pBase, depth + 1, maxDepth,
visited, ranges);

View File

@@ -515,7 +515,7 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
connect(m_sci, &QsciScintilla::textChanged, this, [this]() {
if (!m_editState.active) return;
if (m_updatingComment) return; // Skip queuing during comment update
if (m_editState.target == EditTarget::Value)
if (m_editState.target == EditTarget::Value && !m_editState.hexOverwrite)
QTimer::singleShot(0, this, &RcxEditor::validateEditLive);
// Autocomplete for static field expressions — show field names as user types
@@ -1605,7 +1605,8 @@ RcxEditor::EndEditInfo RcxEditor::endInlineEdit() {
// Dismiss any open user list / autocomplete popup
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCCANCEL);
// Clear edit comment and error marker before deactivating
if (m_editState.target == EditTarget::Value) {
if (m_editState.target == EditTarget::Value
|| (m_editState.hexOverwrite && m_editState.target == EditTarget::Name)) {
setEditComment({}); // Clear to spaces
m_sci->markerDelete(m_editState.line, M_ERR);
}
@@ -2341,6 +2342,10 @@ bool RcxEditor::handleNormalKey(QKeyEvent* ke) {
// ── Edit mode key handling ──
bool RcxEditor::handleEditKey(QKeyEvent* ke) {
// Hex/ASCII overwrite mode: fully custom key handling
if (m_editState.hexOverwrite)
return handleHexEditKey(ke);
// User list is handled via userListActivated signal, not here
// SCI_AUTOCACTIVE is for autocomplete, not user lists
@@ -2440,6 +2445,219 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) {
}
}
// ── Hex/ASCII overwrite-mode key handling ──
bool RcxEditor::handleHexEditKey(QKeyEvent* ke) {
const bool isHexMode = (m_editState.target == EditTarget::Value);
// isHexMode = true: editing "00 00 00 00 00 00 00 00" (hex bytes)
// isHexMode = false: editing "........" (ASCII preview)
int line, col;
m_sci->getCursorPosition(&line, &col);
const int spanStart = m_editState.spanStart;
const int spanEnd = spanStart + m_editState.original.size();
// Helper: replace a single character and re-apply hex dimming indicator
// (SCI_REPLACETARGET can clear indicators at the replacement position)
auto replaceCharAt = [this](long pos, char ch) {
QByteArray buf(1, ch);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, pos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, pos + 1);
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET,
(uintptr_t)1, buf.constData());
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, IND_HEX_DIM);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, 1);
};
switch (ke->key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
commitInlineEdit();
return true;
case Qt::Key_Escape:
cancelInlineEdit();
return true;
case Qt::Key_Tab:
case Qt::Key_Up:
case Qt::Key_Down:
case Qt::Key_PageUp:
case Qt::Key_PageDown:
return true; // block
case Qt::Key_Home:
m_sci->setCursorPosition(line, spanStart);
return true;
case Qt::Key_End: {
// Last data position (last char of span)
int endCol = spanEnd - 1;
if (endCol < spanStart) endCol = spanStart;
m_sci->setCursorPosition(line, endCol);
return true;
}
case Qt::Key_Left: {
if (col <= spanStart) return true;
int newCol = col - 1;
// In hex mode, skip over space separators
if (isHexMode) {
QString lineText = getLineText(m_sci, line);
if (newCol >= spanStart && newCol < lineText.size() && lineText[newCol] == ' ')
newCol--;
}
if (newCol < spanStart) newCol = spanStart;
m_sci->setCursorPosition(line, newCol);
return true;
}
case Qt::Key_Right: {
if (col >= spanEnd - 1) return true;
int newCol = col + 1;
if (isHexMode) {
QString lineText = getLineText(m_sci, line);
if (newCol < spanEnd && newCol < lineText.size() && lineText[newCol] == ' ')
newCol++;
}
if (newCol >= spanEnd) newCol = spanEnd - 1;
m_sci->setCursorPosition(line, newCol);
return true;
}
case Qt::Key_Backspace: {
if (col <= spanStart) return true;
int prevCol = col - 1;
if (isHexMode) {
QString lineText = getLineText(m_sci, line);
if (prevCol >= spanStart && prevCol < lineText.size() && lineText[prevCol] == ' ')
prevCol--;
}
if (prevCol < spanStart) return true;
// Replace previous char with reset value
long pos = posFromCol(m_sci, line, prevCol);
replaceCharAt(pos, isHexMode ? '0' : '.');
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, pos);
return true;
}
case Qt::Key_Delete: {
if (col >= spanEnd) return true;
// Skip space separators in hex mode
if (isHexMode) {
QString lineText = getLineText(m_sci, line);
if (col < lineText.size() && lineText[col] == ' ') return true;
}
// Reset current char
long pos = posFromCol(m_sci, line, col);
replaceCharAt(pos, isHexMode ? '0' : '.');
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, pos);
return true;
}
case Qt::Key_Z:
if (ke->modifiers() & Qt::ControlModifier)
return true; // block Ctrl+Z during hex overwrite
break;
case Qt::Key_V:
if (ke->modifiers() & Qt::ControlModifier) {
QString clip = QApplication::clipboard()->text();
clip.remove('\n');
clip.remove('\r');
if (!clip.isEmpty()) {
QString lineText = getLineText(m_sci, line);
int writeCol = col;
for (int i = 0; i < clip.size() && writeCol < spanEnd; i++) {
QChar ch = clip[i];
if (isHexMode) {
// Skip spaces in paste content
if (ch == ' ') continue;
// Skip over space separators in the target
if (writeCol < lineText.size() && lineText[writeCol] == ' ')
writeCol++;
if (writeCol >= spanEnd) break;
// Only accept hex digits
if (!ch.isDigit() && !(ch >= 'a' && ch <= 'f') && !(ch >= 'A' && ch <= 'F'))
continue;
ch = ch.toUpper();
} else {
// Only accept printable ASCII
if (ch.unicode() < 0x20 || ch.unicode() > 0x7E) continue;
}
long pos = posFromCol(m_sci, line, writeCol);
replaceCharAt(pos, (char)ch.toLatin1());
writeCol++;
// Re-read after each replace for hex space skip
if (isHexMode) lineText = getLineText(m_sci, line);
}
int finalCol = qMin(writeCol, spanEnd - 1);
// In hex mode, if we landed on a space, advance past it
if (isHexMode) {
lineText = getLineText(m_sci, line);
if (finalCol < spanEnd && finalCol < lineText.size() && lineText[finalCol] == ' ')
finalCol++;
if (finalCol >= spanEnd) finalCol = spanEnd - 1;
}
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS,
posFromCol(m_sci, line, finalCol));
}
return true;
}
break;
default:
break;
}
// Character input: overwrite current position and advance
QString text = ke->text();
if (text.isEmpty() || text[0].unicode() < 0x20)
return true; // consume non-printable (block default Scintilla handling)
QChar ch = text[0];
if (isHexMode) {
// Only accept hex digits
if (!ch.isDigit() && !(ch >= 'a' && ch <= 'f') && !(ch >= 'A' && ch <= 'F'))
return true;
ch = ch.toUpper();
// If cursor is on a space, skip to next byte
QString lineText = getLineText(m_sci, line);
int writeCol = col;
if (writeCol < lineText.size() && lineText[writeCol] == ' ')
writeCol++;
if (writeCol >= spanEnd) return true;
// Overwrite current digit
long pos = posFromCol(m_sci, line, writeCol);
replaceCharAt(pos, (char)ch.toLatin1());
// Advance cursor, skip over spaces
int nextCol = writeCol + 1;
lineText = getLineText(m_sci, line);
if (nextCol < spanEnd && nextCol < lineText.size() && lineText[nextCol] == ' ')
nextCol++;
if (nextCol >= spanEnd) nextCol = spanEnd - 1;
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS,
posFromCol(m_sci, line, nextCol));
} else {
// ASCII mode: only printable ASCII
if (ch.unicode() < 0x20 || ch.unicode() > 0x7E)
return true;
if (col >= spanEnd) return true;
// Overwrite current char
long pos = posFromCol(m_sci, line, col);
replaceCharAt(pos, (char)ch.toLatin1());
// Advance cursor
int nextCol = col + 1;
if (nextCol >= spanEnd) nextCol = spanEnd - 1;
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS,
posFromCol(m_sci, line, nextCol));
}
return true;
}
// ── Begin inline edit ──
bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
@@ -2490,14 +2708,39 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
(target == EditTarget::BaseAddress || target == EditTarget::Source
|| target == EditTarget::RootClassType || target == EditTarget::RootClassName)))
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
// Exception: static field names are always editable (they're function names, not hex labels)
if ((target == EditTarget::Name || target == EditTarget::Value) && isHexNode(lm->nodeKind) && !lm->isStaticLine)
// Hex nodes: only Type is editable via normal flow (double-click, F2, Enter)
// Exception: context-menu-initiated hex/ASCII edits bypass this via m_hexEditPending
bool isHexEdit = m_hexEditPending && isHexNode(lm->nodeKind) && !lm->isStaticLine
&& (target == EditTarget::Name || target == EditTarget::Value);
m_hexEditPending = false;
if ((target == EditTarget::Name || target == EditTarget::Value)
&& isHexNode(lm->nodeKind) && !lm->isStaticLine && !isHexEdit)
return false;
QString lineText;
NormalizedSpan norm;
if (!resolvedSpanFor(line, target, norm, &lineText)) return false;
if (isHexEdit) {
// Compute hex spans directly (bypasses resolvedSpanFor which also blocks hex)
lineText = getLineText(m_sci, line);
int typeW = lm->effectiveTypeW;
int nameW = lm->effectiveNameW;
int byteCount = sizeForKind(lm->nodeKind);
if (target == EditTarget::Name) {
// ASCII preview: exactly byteCount chars (no trailing-space trim)
ColumnSpan s = nameSpanFor(*lm, typeW, nameW);
if (!s.valid) return false;
norm = {s.start, s.start + byteCount, true};
} else {
// Hex bytes: "XX XX XX..." = byteCount*3-1 chars
ColumnSpan s = valueSpanFor(*lm, lineText.size(), typeW, nameW);
if (!s.valid) return false;
int hexWidth = byteCount * 3 - 1;
norm = {s.start, s.start + hexWidth, true};
}
} else {
if (!resolvedSpanFor(line, target, norm, &lineText)) return false;
}
QString trimmed = lineText.mid(norm.start, norm.end - norm.start);
@@ -2558,6 +2801,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
m_editState.original = trimmed;
m_editState.linelenAfterReplace = lineText.size();
m_editState.editKind = lm->nodeKind;
m_editState.hexOverwrite = isHexEdit;
if (isVectorKind(lm->nodeKind)) {
m_editState.subLine = vecComponent;
m_editState.editKind = NodeKind::Float;
@@ -2567,9 +2811,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
m_editState.editKind = NodeKind::Float;
}
// Store fixed comment column position for value editing
// Store fixed comment column position for value editing (and hex ASCII edits)
// Use large lineLength so commentCol is always computed (padding added dynamically)
if (target == EditTarget::Value) {
if (target == EditTarget::Value || (isHexEdit && target == EditTarget::Name)) {
ColumnSpan cs = commentSpanFor(*lm, 9999, lm->effectiveTypeW, lm->effectiveNameW);
m_editState.commentCol = cs.valid ? cs.start : -1;
m_editState.lastValidationOk = true; // original value is always valid
@@ -2586,9 +2830,10 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1);
m_sci->setReadOnly(false);
// For value editing: extend line with trailing spaces for the edit comment area
// For value/hex editing: extend line with trailing spaces for the edit comment area
// (comment padding is no longer baked into every line to avoid unnecessary scroll width)
if ((target == EditTarget::Value || target == EditTarget::BaseAddress)
if ((target == EditTarget::Value || target == EditTarget::BaseAddress
|| (isHexEdit && target == EditTarget::Name))
&& m_editState.commentCol >= 0) {
int commentStart = m_editState.commentCol;
int commentWidth = (target == EditTarget::BaseAddress) ? 60 : kColComment;
@@ -2636,10 +2881,19 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
}
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL, selStart, m_editState.posEnd);
// Hex overwrite: place cursor at start, no selection
if (m_editState.hexOverwrite)
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, m_editState.posStart);
// Show initial edit hint in comment column
if (target == EditTarget::Value)
setEditComment(QStringLiteral("Enter=Save Esc=Cancel"));
else if (target == EditTarget::BaseAddress)
if (target == EditTarget::Value) {
if (m_editState.hexOverwrite)
setEditComment(QStringLiteral("Hex edit: Enter=Save Esc=Cancel"));
else
setEditComment(QStringLiteral("Enter=Save Esc=Cancel"));
} else if (target == EditTarget::Name && m_editState.hexOverwrite) {
setEditComment(QStringLiteral("ASCII edit: Enter=Save Esc=Cancel"));
} else if (target == EditTarget::BaseAddress)
setEditComment(QStringLiteral("e.g. <mod.exe> + 0xFF | [0x1000 + 0x10] | 7ff6`1234ABCD"));
// Note: Type, ArrayElementType, PointerTarget are handled by TypeSelectorPopup
@@ -2665,6 +2919,19 @@ void RcxEditor::clampEditSelection() {
if (m_clampingSelection) return;
m_clampingSelection = true;
// Hex overwrite: collapse any selection to cursor (no selection allowed)
if (m_editState.hexOverwrite) {
int sL, sC, eL, eC;
m_sci->getSelection(&sL, &sC, &eL, &eC);
if (sL != eL || sC != eC) {
int curLine, curCol;
m_sci->getCursorPosition(&curLine, &curCol);
m_sci->setCursorPosition(m_editState.line, curCol);
}
m_clampingSelection = false;
return;
}
int selStartLine, selStartCol, selEndLine, selEndCol;
m_sci->getSelection(&selStartLine, &selStartCol, &selEndLine, &selEndCol);
@@ -2710,8 +2977,11 @@ void RcxEditor::commitInlineEdit() {
int editedLen = m_editState.original.size() + delta;
QString editedText;
if (editedLen > 0)
editedText = lineText.mid(m_editState.spanStart, editedLen).trimmed();
if (editedLen > 0) {
editedText = lineText.mid(m_editState.spanStart, editedLen);
if (!m_editState.hexOverwrite)
editedText = editedText.trimmed();
}
// For Type edits: if nothing changed, commit original
if (m_editState.target == EditTarget::Type && editedText.isEmpty())

View File

@@ -51,6 +51,7 @@ public:
bool isEditing() const { return m_editState.active; }
bool beginInlineEdit(EditTarget target, int line = -1, int col = -1);
void cancelInlineEdit();
void setHexEditPending(bool v) { m_hexEditPending = v; }
void setStaticCompletions(const QStringList& words) { m_staticCompletions = words; }
void applySelectionOverlay(const QSet<uint64_t>& selIds);
@@ -143,6 +144,7 @@ private:
NodeKind editKind = NodeKind::Int32;
int commentCol = -1; // fixed comment column (stored at edit start)
bool lastValidationOk = true; // track state to avoid redundant updates
bool hexOverwrite = false; // true for hex-byte / ASCII-preview fixed-length editing
};
InlineEditState m_editState;
QStringList m_staticCompletions; // autocomplete words for StaticExpr editing
@@ -171,6 +173,9 @@ private:
long m_findPos = 0;
void hideFindBar();
// ── Hex inline edit ──
bool m_hexEditPending = false; // set by context menu before calling beginInlineEdit
// ── Reentrancy guards ──
bool m_applyingDocument = false;
bool m_clampingSelection = false;
@@ -195,6 +200,7 @@ private:
int editEndCol() const;
bool handleNormalKey(QKeyEvent* ke);
bool handleEditKey(QKeyEvent* ke);
bool handleHexEditKey(QKeyEvent* ke);
void showTypeAutocomplete();
void showSourcePicker();
void showTypeListFiltered(const QString& filter);