Files
archived-Reclass/src/editor.cpp
IChooseYou 3aeb1a80d5 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.
2026-03-11 16:01:37 -06:00

3955 lines
168 KiB
C++

#include "editor.h"
#include "disasm.h"
#include "providerregistry.h"
#include <QDebug>
#include <Qsci/qsciscintilla.h>
#include <Qsci/qsciscintillabase.h>
#include <Qsci/qscilexercpp.h>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFont>
#include <QColor>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QFocusEvent>
#include <QTimer>
#include <QCursor>
#include <QMenu>
#include <QApplication>
#include <QClipboard>
#include <QLabel>
#include <QToolButton>
#include <QLineEdit>
#include <QScreen>
#include <QScrollBar>
#include <QDateTime>
#include <algorithm>
#include <functional>
#include "themes/thememanager.h"
namespace rcx {
// Forward declaration (defined below, after RcxEditor constructor)
static QString getLineText(QsciScintilla* sci, int line);
// ── Base class for all hover popups ──
class HoverPopup : public QFrame {
protected:
uint64_t m_nodeId = 0;
std::function<void(QMouseEvent*)> m_onMouseMove;
public:
explicit HoverPopup(QWidget* parent)
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
{
setAttribute(Qt::WA_DeleteOnClose, false);
setAttribute(Qt::WA_ShowWithoutActivating, true);
setMouseTracking(true);
setFrameShape(QFrame::NoFrame);
setAutoFillBackground(true);
}
uint64_t nodeId() const { return m_nodeId; }
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
: QRect(0, 0, 1920, 1080);
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
virtual void dismiss() {
if (isVisible()) hide();
m_nodeId = 0;
}
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (m_onMouseMove) m_onMouseMove(e);
else QFrame::mouseMoveEvent(e);
}
void applyThemePalette(const Theme& t) {
QPalette pal;
pal.setColor(QPalette::Window, t.backgroundAlt);
pal.setColor(QPalette::WindowText, t.text);
setPalette(pal);
}
void styleSeparator(const Theme& t) {
for (auto* child : findChildren<QFrame*>()) {
if (child->frameShape() == QFrame::HLine) {
QPalette sp;
sp.setColor(QPalette::WindowText, t.border);
child->setPalette(sp);
break;
}
}
}
};
// ── Title + body popup (used for disasm/hex-dump and struct preview) ──
class TitleBodyPopup : public HoverPopup {
QString m_body;
QLabel* m_titleLabel = nullptr;
QLabel* m_bodyLabel = nullptr;
public:
explicit TitleBodyPopup(QWidget* parent) : HoverPopup(parent) {
auto* vbox = new QVBoxLayout(this);
vbox->setContentsMargins(8, 6, 8, 6);
vbox->setSpacing(2);
m_titleLabel = new QLabel;
QFont bold = m_titleLabel->font();
bold.setBold(true);
m_titleLabel->setFont(bold);
vbox->addWidget(m_titleLabel);
auto* sep = new QFrame;
sep->setFrameShape(QFrame::HLine);
sep->setFrameShadow(QFrame::Plain);
sep->setFixedHeight(1);
vbox->addWidget(sep);
m_bodyLabel = new QLabel;
m_bodyLabel->setTextFormat(Qt::PlainText);
m_bodyLabel->setWordWrap(false);
vbox->addWidget(m_bodyLabel);
}
void populate(uint64_t nodeId, const QString& title, const QString& body,
const QFont& font, const QColor& bodyColor) {
if (nodeId == m_nodeId && body == m_body && isVisible())
return;
m_nodeId = nodeId;
m_body = body;
const auto& theme = ThemeManager::instance().current();
applyThemePalette(theme);
QFont bold = font;
bold.setBold(true);
m_titleLabel->setFont(bold);
m_titleLabel->setText(title);
m_titleLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(theme.text.name()));
styleSeparator(theme);
m_bodyLabel->setFont(font);
m_bodyLabel->setText(body);
m_bodyLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(bodyColor.name()));
setMaximumWidth(600);
adjustSize();
}
void dismiss() override {
HoverPopup::dismiss();
m_body.clear();
}
};
// ── Value history popup ──
class ValueHistoryPopup : public HoverPopup {
bool m_hasButtons = false;
QStringList m_values;
QVector<QLabel*> m_labels;
std::function<void(const QString&)> m_onSet;
public:
explicit ValueHistoryPopup(QWidget* parent) : HoverPopup(parent) {}
bool hasButtons() const { return m_hasButtons; }
void setOnSet(std::function<void(const QString&)> fn) { m_onSet = std::move(fn); }
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (!m_hasButtons && m_onMouseMove)
m_onMouseMove(e);
else
QFrame::mouseMoveEvent(e);
}
public:
void populate(uint64_t nodeId, const ValueHistory& hist, const QFont& font,
bool showButtons = false) {
QStringList vals;
hist.forEach([&](const QString& v) { vals.append(v); });
if (nodeId == m_nodeId && vals == m_values
&& showButtons == m_hasButtons && isVisible())
return;
// In-place label update when structure unchanged (avoids flicker)
if (nodeId == m_nodeId && vals.size() == m_values.size()
&& vals.size() == m_labels.size()
&& showButtons == m_hasButtons && isVisible()) {
for (int i = 0; i < vals.size(); i++)
m_labels[i]->setText(vals[i]);
m_values = vals;
return;
}
m_nodeId = nodeId;
m_values = vals;
m_hasButtons = showButtons;
m_labels.clear();
delete layout();
qDeleteAll(findChildren<QWidget*>(QString(), Qt::FindDirectChildrenOnly));
const auto& theme = ThemeManager::instance().current();
applyThemePalette(theme);
auto* vbox = new QVBoxLayout(this);
vbox->setContentsMargins(8, 6, 8, 6);
vbox->setSpacing(2);
auto* title = new QLabel(QStringLiteral("Previous Values"));
QFont bold = font;
bold.setBold(true);
title->setFont(bold);
title->setStyleSheet(QStringLiteral("color: %1;").arg(theme.text.name()));
vbox->addWidget(title);
auto* sep = new QFrame;
sep->setFrameShape(QFrame::HLine);
sep->setFrameShadow(QFrame::Plain);
sep->setFixedHeight(1);
QPalette sp; sp.setColor(QPalette::WindowText, theme.border);
sep->setPalette(sp);
vbox->addWidget(sep);
qint64 now = QDateTime::currentMSecsSinceEpoch();
hist.forEachWithTime([&](const QString& v, qint64 msec) {
auto* row = new QHBoxLayout;
row->setContentsMargins(0, 1, 0, 1);
row->setSpacing(8);
auto* label = new QLabel(v);
label->setFont(font);
label->setStyleSheet(QStringLiteral("color: %1;").arg(theme.syntaxNumber.name()));
row->addWidget(label, 1);
m_labels.append(label);
if (msec > 0) {
qint64 elapsed = now - msec;
QString timeStr;
if (elapsed < 1000)
timeStr = QStringLiteral("now");
else if (elapsed < 60000)
timeStr = QStringLiteral("%1s ago").arg(elapsed / 1000);
else if (elapsed < 3600000)
timeStr = QStringLiteral("%1m ago").arg(elapsed / 60000);
else
timeStr = QStringLiteral("%1h ago").arg(elapsed / 3600000);
auto* timeLabel = new QLabel(timeStr);
timeLabel->setFont(font);
timeLabel->setStyleSheet(QStringLiteral("color: %1;").arg(theme.textDim.name()));
row->addWidget(timeLabel);
}
if (showButtons) {
auto* setBtn = new QToolButton;
setBtn->setText(QStringLiteral("Set"));
setBtn->setAutoRaise(true);
setBtn->setCursor(Qt::PointingHandCursor);
setBtn->setFont(font);
setBtn->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 1px 4px; }"
"QToolButton:hover { color: %2; background: %3; }")
.arg(theme.textDim.name(), theme.text.name(), theme.hover.name()));
QString val = v;
QObject::connect(setBtn, &QToolButton::clicked, [this, val]() {
if (m_onSet) m_onSet(val);
});
row->addWidget(setBtn);
}
vbox->addLayout(row);
});
adjustSize();
}
void dismiss() override {
HoverPopup::dismiss();
m_values.clear();
m_labels.clear();
}
};
static constexpr int IND_EDITABLE = 8;
static constexpr int IND_HEX_DIM = 9;
static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row 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
static constexpr int IND_HEAT_COLD = 13; // Heatmap level 1 (changed once)
static constexpr int IND_CLASS_NAME = 14; // Teal text for root class name
static constexpr int IND_HINT_GREEN = 15; // Green text for hint/comment text
static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode
static constexpr int IND_HEAT_WARM = 17; // Heatmap level 2 (moderate changes)
static constexpr int IND_HEAT_HOT = 18; // Heatmap level 3 (frequent changes)
static constexpr int IND_FIND = 19; // Search match highlight
static constexpr int IND_TYPE_HINT = 20; // Dimmed type inference hint text on hex nodes
static QString g_fontName = "JetBrains Mono";
static QFont editorFont() {
QFont f(g_fontName, 12);
f.setFixedPitch(true);
return f;
}
RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
m_sci = new QsciScintilla(this);
layout->addWidget(m_sci);
// Find bar (hidden by default, shown with Ctrl+F)
m_findBarContainer = new QWidget(this);
auto* fbLayout = new QHBoxLayout(m_findBarContainer);
fbLayout->setContentsMargins(4, 1, 4, 1);
fbLayout->setSpacing(2);
auto* findPrevBtn = new QToolButton(m_findBarContainer);
findPrevBtn->setText(QStringLiteral("\u25C0"));
findPrevBtn->setFixedSize(24, 24);
auto* findNextBtn = new QToolButton(m_findBarContainer);
findNextBtn->setText(QStringLiteral("\u25B6"));
findNextBtn->setFixedSize(24, 24);
auto* findCloseBtn = new QToolButton(m_findBarContainer);
findCloseBtn->setText(QStringLiteral("\u2715"));
findCloseBtn->setFixedSize(24, 24);
m_findBar = new QLineEdit(m_findBarContainer);
m_findBar->setPlaceholderText(QStringLiteral("Find..."));
m_findBar->setFixedHeight(24);
fbLayout->addWidget(findPrevBtn);
fbLayout->addWidget(findNextBtn);
fbLayout->addWidget(findCloseBtn);
fbLayout->addWidget(m_findBar);
m_findBarContainer->setVisible(false);
layout->addWidget(m_findBarContainer);
setupScintilla();
setupLexer();
setupMargins();
setupFolding();
setupMarkers();
allocateMarginStyles();
applyTheme(ThemeManager::instance().current());
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
this, &RcxEditor::applyTheme);
m_sci->installEventFilter(this);
m_sci->viewport()->installEventFilter(this);
m_sci->viewport()->setMouseTracking(true);
// Find bar: indicator-based search (selection is disabled in our Scintilla)
auto doFind = [this](bool forward) {
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, (long)0, docLen);
QString text = m_findBar->text();
if (text.isEmpty()) return;
QByteArray needle = text.toUtf8();
long startPos = forward ? m_findPos : (m_findPos > 0 ? m_findPos - 1 : docLen);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, startPos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND,
forward ? docLen : (long)0);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEARCHFLAGS, (long)0);
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
(uintptr_t)needle.size(), needle.constData());
if (pos == -1) { // wrap
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART,
forward ? (long)0 : docLen);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND,
forward ? startPos : (long)0);
pos = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
(uintptr_t)needle.size(), needle.constData());
}
if (pos >= 0) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, (long)needle.size());
int line = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_LINEFROMPOSITION, pos);
m_sci->ensureLineVisible(line);
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, pos);
m_findPos = pos + (forward ? needle.size() : 0);
}
};
connect(m_findBar, &QLineEdit::textChanged, this, [doFind]() { doFind(true); });
connect(m_findBar, &QLineEdit::returnPressed, this, [doFind]() { doFind(true); });
connect(findNextBtn, &QToolButton::clicked, this, [doFind]() { doFind(true); });
connect(findPrevBtn, &QToolButton::clicked, this, [doFind]() { doFind(false); });
connect(findCloseBtn, &QToolButton::clicked, this, [this]() { hideFindBar(); });
// Escape hides find bar
{
auto* escAction = new QAction(m_findBar);
escAction->setShortcut(QKeySequence(Qt::Key_Escape));
escAction->setShortcutContext(Qt::WidgetShortcut);
m_findBar->addAction(escAction);
connect(escAction, &QAction::triggered, this, [this]() { hideFindBar(); });
}
// Recalculate hover when the viewport scrolls (scrollbar drag, wheel
// deceleration, etc.) so the highlight tracks whatever is under the cursor.
connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged,
this, [this]() {
if (m_editState.active || !m_hoverInside) return;
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
m_hoverInside = m_sci->viewport()->rect().contains(m_lastHoverPos);
auto h = hitTest(m_lastHoverPos);
uint64_t newHoverId = (m_hoverInside && h.line >= 0) ? h.nodeId : 0;
int newHoverLine = (m_hoverInside && h.line >= 0) ? h.line : -1;
if (newHoverId != m_hoveredNodeId || newHoverLine != m_hoveredLine) {
m_hoveredNodeId = newHoverId;
m_hoveredLine = newHoverLine;
applyHoverHighlight();
}
applyHoverCursor();
});
// Hover cursor is applied synchronously in eventFilter (no timer).
connect(m_sci, &QsciScintilla::marginClicked,
this, [this](int margin, int line, Qt::KeyboardModifiers mods) {
emit marginClicked(margin, line, mods);
});
m_sci->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_sci, &QWidget::customContextMenuRequested,
this, [this](const QPoint& pos) {
// Right-click on offset margin → show margin mode menu
int margin0Width = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_GETMARGINWIDTHN, 0UL, 0L);
if (pos.x() < margin0Width) {
QMenu menu;
auto* actRel = menu.addAction("Relative Offsets (+0x)");
auto* actAbs = menu.addAction("Absolute Addresses");
actRel->setCheckable(true);
actAbs->setCheckable(true);
actRel->setChecked(m_relativeOffsets);
actAbs->setChecked(!m_relativeOffsets);
QAction* chosen = menu.exec(m_sci->mapToGlobal(pos));
if (chosen == actRel && !m_relativeOffsets) {
m_relativeOffsets = true;
reformatMargins();
emit relativeOffsetsChanged(true);
} else if (chosen == actAbs && m_relativeOffsets) {
m_relativeOffsets = false;
reformatMargins();
emit relativeOffsetsChanged(false);
}
return;
}
HitInfo hi = hitTest(pos);
int line = hi.line;
// Right-click on command row keyword → show conversion menu
if (line == 0 && hi.col >= 0 && !m_meta.isEmpty()
&& m_meta[0].lineKind == LineKind::CommandRow) {
QString lineText = getLineText(m_sci, 0);
ColumnSpan rts = commandRowRootTypeSpan(lineText);
if (rts.valid && hi.col >= rts.start && hi.col < rts.end) {
// Extract current keyword from span text
QString kw = lineText.mid(rts.start, rts.end - rts.start).trimmed();
QMenu menu;
if (kw == QStringLiteral("class"))
menu.addAction("Convert to Struct");
else if (kw == QStringLiteral("struct"))
menu.addAction("Convert to Class");
// enum: no conversion options
if (!menu.isEmpty()) {
QAction* chosen = menu.exec(m_sci->mapToGlobal(pos));
if (chosen) {
QString newKw = chosen->text().contains("Class")
? QStringLiteral("class") : QStringLiteral("struct");
emit keywordConvertRequested(newKw);
}
}
return;
}
}
int nodeIdx = -1;
int subLine = 0;
if (line >= 0 && line < m_meta.size()) {
nodeIdx = m_meta[line].nodeIdx;
subLine = m_meta[line].subLine;
}
emit contextMenuRequested(line, nodeIdx, subLine, m_sci->mapToGlobal(pos));
});
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
|| m_editState.target == EditTarget::ArrayElementType
|| m_editState.target == EditTarget::PointerTarget)) {
const LineMeta* lm = metaForLine(m_editState.line);
uint64_t addr = lm ? lm->offsetAddr : 0;
auto info = endInlineEdit();
emit inlineEditCommitted(info.nodeIdx, info.subLine, info.target, text, addr);
}
});
connect(m_sci, &QsciScintilla::cursorPositionChanged,
this, [this](int line, int /*col*/) { updateEditableIndicators(line); });
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 && !m_editState.hexOverwrite)
QTimer::singleShot(0, this, &RcxEditor::validateEditLive);
// Autocomplete for static field expressions — show field names as user types
if (m_editState.target == EditTarget::StaticExpr && !m_staticCompletions.isEmpty()) {
// Get word at cursor
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS);
long wordStart = m_sci->SendScintilla(QsciScintillaBase::SCI_WORDSTARTPOSITION, pos, (long)1);
int wordLen = (int)(pos - wordStart);
if (wordLen >= 1) {
QByteArray list = m_staticCompletions.join(' ').toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)' ');
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSHOW, (uintptr_t)wordLen, list.constData());
}
}
});
connect(m_sci, &QsciScintilla::selectionChanged,
this, &RcxEditor::clampEditSelection);
}
RcxEditor::~RcxEditor() {
}
void RcxEditor::setupScintilla() {
m_sci->setFont(editorFont());
m_sci->setReadOnly(true);
m_sci->setWrapMode(QsciScintilla::WrapNone);
m_sci->setCaretLineVisible(false);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 0);
// Arrow cursor by default — not the I-beam (this is a structured viewer, not a text editor)
m_sci->viewport()->setCursor(Qt::ArrowCursor);
m_sci->setTabWidth(2);
m_sci->setIndentationsUseTabs(false);
// Line spacing for readability
m_sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2);
// Disable native selection rendering — we use markers for selection
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELFORE, (long)0, (long)0);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELBACK, (long)0, (long)0);
// Horizontal scrollbar: sized explicitly in applyDocument() to match content
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSCROLLWIDTHTRACKING, 0);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSCROLLWIDTH, 1);
// Vertical scrollbar: don't allow scrolling past the last line
m_sci->SendScintilla(QsciScintillaBase::SCI_SETENDATLASTLINE, 1);
// Editable-field indicator - HIDDEN (no visual)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_EDITABLE, 5 /*INDIC_HIDDEN*/);
// Hex node dim indicator — overrides text color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEX_DIM, 17 /*INDIC_TEXTFORE*/);
// Base address indicator — text color override on command row
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_BASE_ADDR, 17 /*INDIC_TEXTFORE*/);
// Hover span indicator — link-like text
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HOVER_SPAN, 17 /*INDIC_TEXTFORE*/);
// Command-row pill background
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_CMD_PILL, 8 /*INDIC_STRAIGHTBOX*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETALPHA,
IND_CMD_PILL, (long)100);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER,
IND_CMD_PILL, (long)1);
// Heatmap indicators (cold / warm / hot)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEAT_COLD, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEAT_WARM, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEAT_HOT, 17 /*INDIC_TEXTFORE*/);
// Root class name — type color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_CLASS_NAME, 17 /*INDIC_TEXTFORE*/);
// Green text for hint/comment annotations
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HINT_GREEN, 17 /*INDIC_TEXTFORE*/);
// Local offset text color (dim, like margin text)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_LOCAL_OFF, 17 /*INDIC_TEXTFORE*/);
// Type inference hint — dimmed text appended to hex lines
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_TYPE_HINT, 17 /*INDIC_TEXTFORE*/);
// Find match highlight — thick underline (avoids box rendering artifacts)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_FIND, 14 /*INDIC_COMPOSITIONTHICK*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER,
IND_FIND, (long)1);
}
void RcxEditor::setupLexer() {
m_lexer = new QsciLexerCPP(m_sci);
QFont font = editorFont();
m_lexer->setFont(font);
for (int i = 0; i <= 127; i++)
m_lexer->setFont(font, i);
m_sci->setLexer(m_lexer);
m_sci->setBraceMatching(QsciScintilla::NoBraceMatch); // Disable - this is a structured viewer
// Add built-in type names to keyword set 1 → blue coloring
QByteArray kw2 = allTypeNamesForUI(/*stripBrackets=*/true).join(' ').toLatin1();
m_sci->SendScintilla(QsciScintillaBase::SCI_SETKEYWORDS,
(uintptr_t)1, kw2.constData());
}
void RcxEditor::setCustomTypeNames(const QStringList& names) {
m_customTypeNames = names;
QByteArray kw = names.join(' ').toLatin1();
m_sci->SendScintilla(QsciScintillaBase::SCI_SETKEYWORDS,
(uintptr_t)3, kw.constData());
}
void RcxEditor::setupMargins() {
m_sci->setMarginsFont(editorFont());
// Margin 0: Offset text
m_sci->setMarginType(0, QsciScintilla::TextMarginRightJustified);
m_sci->setMarginWidth(0, " 00000000 "); // default 8-digit; resized dynamically in applyDocument()
m_sci->setMarginSensitivity(0, true);
// Margin 1: 2px accent bar (selection indicator)
m_sci->setMarginType(1, QsciScintilla::SymbolMargin);
m_sci->setMarginWidth(1, 2);
m_sci->setMarginSensitivity(1, false);
m_sci->setMarginMarkerMask(1, 1 << M_ACCENT);
}
void RcxEditor::setupFolding() {
// Hide fold margin (fold indicators are text-based now)
m_sci->setMarginWidth(2, 0);
// Fold indicators are now text in the line content (kFoldCol prefix),
// so no Scintilla markers needed for fold state.
// Keep Scintilla fold markers invisible (fold levels still used for click detection)
for (int i = 25; i <= 31; i++)
m_sci->markerDefine(QsciScintilla::Invisible, i);
// Disable automatic fold toggle — we handle collapse at model level
m_sci->SendScintilla(QsciScintillaBase::SCI_SETAUTOMATICFOLD,
(unsigned long)0);
// Disable lexer-driven folding — we set fold levels manually
m_sci->SendScintilla(QsciScintillaBase::SCI_SETPROPERTY,
(const char*)"fold", (const char*)"0");
}
void RcxEditor::setupMarkers() {
// M_CONT (0): continuation line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_CONT);
// M_PTR0 (2): right triangle
m_sci->markerDefine(QsciScintilla::RightTriangle, M_PTR0);
// M_CYCLE (3): arrows
m_sci->markerDefine(QsciScintilla::ThreeRightArrows, M_CYCLE);
// M_ERR (4): background
m_sci->markerDefine(QsciScintilla::Background, M_ERR);
// M_STRUCT_BG (5): struct header/footer
m_sci->markerDefine(QsciScintilla::Background, M_STRUCT_BG);
// M_HOVER (6): full-row hover highlight
m_sci->markerDefine(QsciScintilla::Background, M_HOVER);
// M_SELECTED (7): full-row selection highlight
m_sci->markerDefine(QsciScintilla::Background, M_SELECTED);
// M_CMD_ROW (8): distinct background for CommandRow bar
m_sci->markerDefine(QsciScintilla::Background, M_CMD_ROW);
// M_ACCENT (9): 2px accent bar in margin 1 (selection indicator)
m_sci->markerDefine(QsciScintilla::FullRectangle, M_ACCENT);
}
void RcxEditor::allocateMarginStyles() {
static constexpr int MSTYLE_NORMAL = 0;
static constexpr int MSTYLE_CONT = 1;
long base = m_sci->SendScintilla(QsciScintillaBase::SCI_ALLOCATEEXTENDEDSTYLES, (long)2);
m_marginStyleBase = (int)base;
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETSTYLEOFFSET, base);
QByteArray fontName = editorFont().family().toUtf8();
int fontSize = editorFont().pointSize();
for (int s = MSTYLE_NORMAL; s <= MSTYLE_CONT; s++) {
unsigned long abs = (unsigned long)(base + s);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETFONT,
(uintptr_t)abs, fontName.constData());
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETSIZE, abs, (long)fontSize);
}
}
void RcxEditor::applyTheme(const Theme& theme) {
// Editor uses a slightly darker background than chrome for visual depth
const QColor editorBg = theme.background.darker(115);
// Paper and text
m_sci->setPaper(editorBg);
m_sci->setColor(theme.text);
m_sci->setCaretForegroundColor(theme.text);
// Indicator colors
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEX_DIM, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_BASE_ADDR, theme.text);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HOVER_SPAN, theme.indHoverSpan);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CMD_PILL, theme.indCmdPill);
// Heatmap colors
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEAT_COLD, theme.indHeatCold);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEAT_WARM, theme.indHeatWarm);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEAT_HOT, theme.indHeatHot);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CLASS_NAME, theme.syntaxType);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HINT_GREEN, theme.indHintGreen);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_LOCAL_OFF, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_TYPE_HINT, theme.indHintGreen);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_FIND, theme.borderFocused);
// Lexer colors
m_lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::Keyword);
m_lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::KeywordSet2);
m_lexer->setColor(theme.syntaxNumber, QsciLexerCPP::Number);
m_lexer->setColor(theme.syntaxString, QsciLexerCPP::DoubleQuotedString);
m_lexer->setColor(theme.syntaxString, QsciLexerCPP::SingleQuotedString);
m_lexer->setColor(theme.syntaxComment, QsciLexerCPP::Comment);
m_lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentLine);
m_lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentDoc);
m_lexer->setColor(theme.text, QsciLexerCPP::Default);
m_lexer->setColor(theme.text, QsciLexerCPP::Identifier);
m_lexer->setColor(theme.syntaxPreproc, QsciLexerCPP::PreProcessor);
m_lexer->setColor(theme.text, QsciLexerCPP::Operator);
m_lexer->setColor(theme.syntaxType, QsciLexerCPP::GlobalClass);
for (int i = 0; i <= 127; i++)
m_lexer->setPaper(editorBg, i);
// Margins
m_sci->setMarginsBackgroundColor(editorBg);
m_sci->setMarginsForegroundColor(theme.textFaint);
m_sci->setFoldMarginColors(editorBg, editorBg);
// Markers
m_sci->setMarkerBackgroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerForegroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerBackgroundColor(editorBg, M_CYCLE);
m_sci->setMarkerForegroundColor(editorBg, M_CYCLE);
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
m_sci->setMarkerForegroundColor(theme.text, M_ERR);
m_sci->setMarkerBackgroundColor(editorBg, M_STRUCT_BG);
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
m_sci->setMarkerBackgroundColor(theme.selected, M_SELECTED);
m_sci->setMarkerBackgroundColor(editorBg, M_CMD_ROW);
m_sci->setMarkerBackgroundColor(theme.indHoverSpan, M_ACCENT);
// Margin extended styles
if (m_marginStyleBase >= 0) {
long base = m_marginStyleBase;
for (int s = 0; s <= 1; s++) {
unsigned long abs = (unsigned long)(base + s);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETFORE,
abs, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETBACK,
abs, editorBg);
}
}
// Find bar
if (m_findBarContainer) {
m_findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 2px 6px; font-size: 13px; }")
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
m_findBarContainer->setStyleSheet(
QStringLiteral("QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
"QToolButton:hover { background: %4; }"
"QToolButton:pressed { background: %5; }")
.arg(theme.background.name(), theme.text.name(), theme.border.name(),
theme.hover.name(), theme.backgroundAlt.name()));
}
}
void RcxEditor::applyDocument(const ComposeResult& result) {
// Silently deactivate inline edit (no signal — refresh is already happening)
if (m_editState.active)
endInlineEdit();
// Guard: suppress popup dismiss during setText() which fires synthetic Leave events
m_applyingDocument = true;
// Save hover state — setText() triggers viewport Leave events that would clear it
uint64_t savedHoverId = m_hoveredNodeId;
int savedHoverLine = m_hoveredLine;
bool savedHoverInside = m_hoverInside;
m_meta = result.meta;
m_layout = result.layout;
// Build nodeId → display-line index for O(1) hover/selection lookup
m_nodeLineIndex.clear();
m_nodeLineIndex.reserve(m_meta.size());
for (int i = 0; i < m_meta.size(); i++) {
if (m_meta[i].nodeId != 0)
m_nodeLineIndex[m_meta[i].nodeId].append(i);
}
// Dynamically resize margin to fit the current hex digit tier
// RVA mode uses half width since relative offsets are much shorter
{
int marginDigits = m_relativeOffsets
? qMax(m_layout.offsetHexDigits / 2, 4)
: m_layout.offsetHexDigits;
QString marginSizer = QString(" %1 ").arg(QString(marginDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
}
m_sci->setReadOnly(false);
m_sci->setText(result.text);
m_sci->setReadOnly(true);
// Set horizontal scroll width to match the longest line (ignoring trailing spaces).
// Single-pass scan avoids QString::split() allocation of entire QStringList.
{
int maxLen = 0, curLen = 0, lastNonSpace = 0;
for (int i = 0; i < result.text.size(); i++) {
QChar ch = result.text[i];
if (ch == '\n') {
maxLen = qMax(maxLen, lastNonSpace);
curLen = 0;
lastNonSpace = 0;
} else {
++curLen;
if (ch != ' ') lastNonSpace = curLen;
}
}
maxLen = qMax(maxLen, lastNonSpace);
QFontMetrics fm(editorFont());
int pixelWidth = fm.horizontalAdvance(QString(maxLen, QChar('0')));
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSCROLLWIDTH,
(unsigned long)qMax(1, pixelWidth));
// Reset horizontal scroll to 0. The controller's restoreViewState()
// will set it back to the (clamped) saved position afterward.
m_sci->SendScintilla(QsciScintillaBase::SCI_SETXOFFSET, (unsigned long)0);
}
// Force full re-lex to fix stale syntax coloring after edits
m_sci->SendScintilla(QsciScintillaBase::SCI_COLOURISE, (uintptr_t)0, (long)-1);
applyLineAttributes(result.meta);
applyHexDimming(result.meta);
// Build line-text cache for indicator passes (avoids redundant Scintilla IPC)
QVector<QString> lineTexts(result.meta.size());
for (int i = 0; i < result.meta.size(); i++) {
const auto& lm = result.meta[i];
if (lm.heatLevel > 0 || isFuncPtr(lm.nodeKind) ||
lm.nodeKind == NodeKind::Pointer32 ||
lm.nodeKind == NodeKind::Pointer64 ||
lm.lineKind == LineKind::Footer ||
lm.typeHintStart >= 0)
lineTexts[i] = getLineText(m_sci, i);
}
applyHeatmapHighlight(result.meta, lineTexts);
applySymbolColoring(result.meta, lineTexts);
applyCommandRowPills();
// Footer buttons — pill styling
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].lineKind != LineKind::Footer) continue;
const QString& ft = lineTexts[i];
// Struct footer: +10h +100h +1000h Trim (search longest first)
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
if (p1000 >= 0)
fillIndicatorCols(IND_CMD_PILL, i, p1000, p1000 + 6);
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1)
fillIndicatorCols(IND_CMD_PILL, i, p100, p100 + 5);
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000)
fillIndicatorCols(IND_CMD_PILL, i, p10, p10 + 4);
// Enum footer: +10 (no 'h')
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000)
fillIndicatorCols(IND_CMD_PILL, i, add10Start, add10Start + 3);
int trimStart = ft.indexOf(QStringLiteral("Trim"));
if (trimStart >= 0)
fillIndicatorCols(IND_CMD_PILL, i, trimStart, trimStart + 4);
}
// Apply type inference hint coloring (green, same as comment annotations)
for (int i = 0; i < result.meta.size(); i++) {
const auto& lm = result.meta[i];
if (lm.typeHintStart < 0) continue;
const QString& ft = lineTexts[i];
if (lm.typeHintStart < ft.size())
fillIndicatorCols(IND_TYPE_HINT, i, lm.typeHintStart, ft.size());
}
// Reset hint line - applySelectionOverlay will repaint indicators
m_hintLine = -1;
// Restore hover state — but clear if the node was deleted
m_hoveredNodeId = savedHoverId;
m_hoveredLine = savedHoverLine;
m_hoverInside = savedHoverInside;
m_applyingDocument = false;
if (m_hoveredNodeId != 0 && !m_nodeLineIndex.contains(m_hoveredNodeId)) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
dismissAllPopups();
}
// Re-apply hover markers (setText() clears all Scintilla markers).
// Reset m_prevHoveredNodeId so the incremental logic re-adds markers.
// applyHoverCursor() is NOT called here — it evaluates hitTest() against
// composed text that updateCommandRow() will overwrite. The correct call
// happens via applySelectionOverlays() after all text is finalized.
m_prevHoveredNodeId = 0;
m_prevHoveredLine = -1;
applyHoverHighlight();
// Re-apply find indicator (setText() clears all indicators)
if (m_findBarContainer && m_findBarContainer->isVisible()) {
QString needle = m_findBar->text();
if (!needle.isEmpty()) {
QByteArray nb = needle.toUtf8();
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEARCHFLAGS, (long)0);
long pos = 0;
while (pos < docLen) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, pos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, docLen);
long found = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
(uintptr_t)nb.size(), nb.constData());
if (found < 0) break;
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, found, (long)nb.size());
pos = found + nb.size();
}
}
}
}
void RcxEditor::applyLineAttributes(const QVector<LineMeta>& meta) {
// Margin text
if (m_relativeOffsets) {
reformatMargins();
} else {
m_sci->clearMarginText(-1);
}
// Clear markers
for (int m = M_CONT; m <= M_STRUCT_BG; m++)
m_sci->markerDeleteAll(m);
m_sci->markerDeleteAll(M_CMD_ROW);
// Single pass: margin text (absolute mode), markers, fold levels
for (int i = 0; i < meta.size(); i++) {
const auto& lm = meta[i];
// Margin text (only in absolute offset mode; reformatMargins handles relative)
if (!m_relativeOffsets && !lm.offsetText.isEmpty()) {
QByteArray text = lm.offsetText.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETTEXT,
(uintptr_t)i, text.constData());
QByteArray styles(text.size(), '\0');
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETSTYLES,
(uintptr_t)i, styles.constData());
}
// Markers
if (lm.lineKind == LineKind::CommandRow) {
m_sci->markerAdd(i, M_CMD_ROW);
} else {
uint32_t mask = lm.markerMask;
for (int m = M_CONT; m <= M_STRUCT_BG; m++) {
if (mask & (1u << m))
m_sci->markerAdd(i, m);
}
}
// Fold level
m_sci->SendScintilla(QsciScintillaBase::SCI_SETFOLDLEVEL,
(unsigned long)i, (long)lm.foldLevel);
}
}
void RcxEditor::reformatMargins() {
uint64_t base = m_layout.baseAddress;
int hexDigits = m_layout.offsetHexDigits;
// Resize margin: RVA offsets are much shorter than full addresses
int marginDigits = m_relativeOffsets ? qMax(hexDigits / 2, 4) : hexDigits;
QString marginSizer = QString(" %1 ").arg(QString(marginDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
// ── Pass 1: margin text (global offset only) ──
m_sci->clearMarginText(-1);
for (int i = 0; i < m_meta.size(); i++) {
auto& lm = m_meta[i];
if (lm.isContinuation || lm.isMemberLine) {
lm.offsetText = QStringLiteral(" \u00B7 ");
} else if (lm.offsetText.isEmpty()) {
continue;
} else if (m_relativeOffsets) {
if (lm.lineKind == LineKind::Footer ||
lm.lineKind == LineKind::ArrayElementSeparator ||
lm.lineKind == LineKind::CommandRow) {
lm.offsetText = QString(hexDigits + 1, ' ');
} else {
uint64_t rvaBase = lm.ptrBase ? lm.ptrBase : base;
uint64_t rel = lm.offsetAddr >= rvaBase ? lm.offsetAddr - rvaBase : 0;
lm.offsetText = (QStringLiteral("+") +
QString::number(rel, 16).toUpper())
.rightJustified(hexDigits, ' ') + QChar(' ');
}
} else {
lm.offsetText = QString::number(lm.offsetAddr, 16).toUpper()
.rightJustified(hexDigits, '0') + QChar(' ');
}
QByteArray text = lm.offsetText.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETTEXT,
(uintptr_t)i, text.constData());
QByteArray styles(text.size(), '\0');
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETSTYLES,
(uintptr_t)i, styles.constData());
}
// ── Pass 2: inline local offsets in the text indent area ──
// Skip when tree lines are active — the compose step already placed
// Unicode tree connectors in the indent area; overwriting with spaces
// or offsets would destroy them.
if (m_layout.treeLines)
return;
m_sci->setReadOnly(false);
for (int i = 0; i < m_meta.size(); i++) {
const auto& lm = m_meta[i];
if (lm.depth <= 1 || lm.isContinuation) continue;
if (lm.lineKind != LineKind::Field && lm.lineKind != LineKind::Header)
continue;
// Place offset in the parent's indent slot (one level above the field's own indent)
// so the field's own 3-char indent acts as visual separator from the type column
int col = kFoldCol + (lm.depth - 2) * 3;
int slotWidth = 5;
auto pos = [&](int c) -> long {
return m_sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)i, (long)c);
};
if (m_relativeOffsets) {
// Derive local offset: for pointer-expanded children use ptrBase,
// otherwise find enclosing header or array element separator
uint64_t parentAddr = base;
if (lm.ptrBase != 0) {
parentAddr = lm.ptrBase;
} else {
for (int j = i - 1; j >= 0; j--) {
const auto& pLm = m_meta[j];
if (pLm.lineKind == LineKind::Header && pLm.depth < lm.depth) {
parentAddr = pLm.offsetAddr;
break;
}
if (pLm.lineKind == LineKind::ArrayElementSeparator && pLm.depth <= lm.depth) {
parentAddr = pLm.offsetAddr;
break;
}
}
}
uint64_t localOff = lm.offsetAddr >= parentAddr ? lm.offsetAddr - parentAddr : 0;
QString off = QStringLiteral("+") +
QString::number(localOff, 16).toUpper();
QString padded = off.size() <= slotWidth
? off.rightJustified(slotWidth, ' ')
: off;
long posA = pos(col);
long posB = pos(col + slotWidth);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, posA);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, posB);
QByteArray utf8 = padded.left(slotWidth).toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET,
(uintptr_t)utf8.size(), utf8.constData());
// Color the local offset dim
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, IND_LOCAL_OFF);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE,
posA, posB - posA);
} else {
// Restore spaces when toggling off
long posA = pos(col);
long posB = pos(col + slotWidth);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, posA);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, posB);
QByteArray spaces(slotWidth, ' ');
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET,
(uintptr_t)spaces.size(), spaces.constData());
}
}
m_sci->setReadOnly(true);
}
static inline void lineRangeNoEol(QsciScintilla* sci, int line, long& start, long& len) {
start = sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)line);
long end = sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, (unsigned long)line);
len = (end > start) ? (end - start) : 0;
}
// UTF-8 safe column-to-position conversion
static inline long posFromCol(QsciScintilla* sci, int line, int col) {
return sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)line, (long)col);
}
void RcxEditor::clearIndicatorLine(int indic, int line) {
if (line < 0) return;
long start, len;
lineRangeNoEol(m_sci, line, start, len);
if (len <= 0) return;
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, indic);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, start, len);
}
void RcxEditor::fillIndicatorCols(int indic, int line, int colA, int colB) {
long a = posFromCol(m_sci, line, colA);
long b = posFromCol(m_sci, line, colB);
if (b > a) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, indic);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, a, b - a);
}
}
void RcxEditor::applyHexDimming(const QVector<LineMeta>& meta) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, IND_HEX_DIM);
for (int i = 0; i < meta.size(); i++) {
// Dim fold arrows (▸/▾) on fold head lines
if (meta[i].foldHead && meta[i].lineKind != LineKind::CommandRow)
fillIndicatorCols(IND_HEX_DIM, i, 0, kFoldCol);
if (isHexPreview(meta[i].nodeKind)) {
long pos, len; lineRangeNoEol(m_sci, i, pos, len);
if (len > 0)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, len);
}
// Dim struct/array braces: entire footer line, trailing "{" on headers
if (meta[i].lineKind == LineKind::Footer) {
long pos, len; lineRangeNoEol(m_sci, i, pos, len);
if (len > 0)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, len);
} else if (meta[i].lineKind == LineKind::Header ||
meta[i].lineKind == LineKind::CommandRow) {
long endPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, (unsigned long)i);
for (long p = endPos - 1; p >= 0; --p) {
int ch = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETCHARAT, (unsigned long)p);
if (ch == ' ' || ch == '\t') continue;
if (ch == '{')
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, p, 1);
break;
}
}
}
}
void RcxEditor::applySelectionOverlay(const QSet<uint64_t>& selIds) {
m_currentSelIds = selIds;
m_sci->markerDeleteAll(M_SELECTED);
m_sci->markerDeleteAll(M_ACCENT);
// Clear all editable indicators, then repaint for selected lines only
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, IND_EDITABLE);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, (unsigned long)0, docLen);
// Use index: iterate selected IDs, look up their lines
for (uint64_t selId : selIds) {
bool isFooterSel = (selId & kFooterIdBit) != 0;
bool isArrayElemSel = (selId & kArrayElemBit) != 0;
bool isMemberSel = (selId & kMemberBit) != 0;
int arrayElemIdx = isArrayElemSel ? arrayElemIdxFromSelId(selId) : -1;
int memberSubLine = isMemberSel ? memberSubFromSelId(selId) : -1;
uint64_t nodeId = selId & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask
| kMemberBit | kMemberSubMask);
auto it = m_nodeLineIndex.constFind(nodeId);
if (it == m_nodeLineIndex.constEnd()) continue;
for (int ln : *it) {
if (isSyntheticLine(m_meta[ln])) continue;
bool isFooter = (m_meta[ln].lineKind == LineKind::Footer);
// Match selection type to line type
if (isFooterSel && !isFooter) continue;
if (!isFooterSel && isFooter) continue;
// Array element: match by element index
if (isArrayElemSel) {
if (!m_meta[ln].isArrayElement || m_meta[ln].arrayElementIdx != arrayElemIdx)
continue;
} else if (m_meta[ln].isArrayElement) {
continue;
}
// Member line: match by subLine index
if (isMemberSel) {
if (!m_meta[ln].isMemberLine || m_meta[ln].subLine != memberSubLine)
continue;
} else if (m_meta[ln].isMemberLine) {
continue;
}
m_sci->markerAdd(ln, M_SELECTED);
m_sci->markerAdd(ln, M_ACCENT);
if (!isFooter)
paintEditableSpans(ln);
}
}
// Reset hint line - updateEditableIndicators will handle cursor hints
// on actual user navigation (not stale restored positions)
m_hintLine = -1;
applyHoverHighlight();
applyHoverCursor();
}
void RcxEditor::applyHoverHighlight() {
uint64_t prevId = m_prevHoveredNodeId;
int prevLine = m_prevHoveredLine;
m_prevHoveredNodeId = m_hoveredNodeId;
m_prevHoveredLine = m_hoveredLine;
// Fast path: nothing changed (same node AND same line)
if (prevId == m_hoveredNodeId && prevLine == m_hoveredLine
&& m_hoveredNodeId != 0) return;
// Remove old hover markers
if (prevId != 0) {
// Check if old hovered line was a single-line highlight (footer or array element)
bool prevSingleLine = (prevLine >= 0 && prevLine < m_meta.size() &&
(m_meta[prevLine].lineKind == LineKind::Footer || m_meta[prevLine].isArrayElement
|| m_meta[prevLine].isMemberLine));
if (prevSingleLine) {
m_sci->markerDelete(prevLine, M_HOVER);
} else {
auto it = m_nodeLineIndex.constFind(prevId);
if (it != m_nodeLineIndex.constEnd()) {
for (int ln : *it)
m_sci->markerDelete(ln, M_HOVER);
}
}
}
if (m_editState.active) return;
if (!m_hoverInside) return;
if (m_hoveredNodeId == 0) return;
// Footer, array elements, and member lines highlight only the specific line
bool hoveringFooter = (m_hoveredLine >= 0 && m_hoveredLine < m_meta.size() &&
m_meta[m_hoveredLine].lineKind == LineKind::Footer);
bool hoveringArrayElem = (m_hoveredLine >= 0 && m_hoveredLine < m_meta.size() &&
m_meta[m_hoveredLine].isArrayElement);
bool hoveringMember = (m_hoveredLine >= 0 && m_hoveredLine < m_meta.size() &&
m_meta[m_hoveredLine].isMemberLine);
// Check if the hovered item is already selected (using appropriate ID)
uint64_t checkId;
if (hoveringFooter)
checkId = m_hoveredNodeId | kFooterIdBit;
else if (hoveringArrayElem)
checkId = makeArrayElemSelId(m_hoveredNodeId, m_meta[m_hoveredLine].arrayElementIdx);
else if (hoveringMember)
checkId = makeMemberSelId(m_hoveredNodeId, m_meta[m_hoveredLine].subLine);
else
checkId = m_hoveredNodeId;
if (m_currentSelIds.contains(checkId)) return;
if (hoveringFooter || hoveringArrayElem || hoveringMember) {
// Single-line highlight for footers, array elements, and member lines
m_sci->markerAdd(m_hoveredLine, M_HOVER);
} else {
// Non-footer, non-array-element: highlight all lines for this node
auto it = m_nodeLineIndex.constFind(m_hoveredNodeId);
if (it != m_nodeLineIndex.constEnd()) {
for (int ln : *it) {
if (m_meta[ln].lineKind != LineKind::Footer &&
!m_meta[ln].isArrayElement)
m_sci->markerAdd(ln, M_HOVER);
}
}
}
}
ViewState RcxEditor::saveViewState() const {
ViewState vs;
vs.scrollLine = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETFIRSTVISIBLELINE);
int line, col;
m_sci->getCursorPosition(&line, &col);
vs.cursorLine = line;
vs.cursorCol = col;
vs.xOffset = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETXOFFSET);
return vs;
}
void RcxEditor::restoreViewState(const ViewState& vs) {
int maxLine = std::max(0, m_sci->lines() - 1);
int line = std::clamp(vs.cursorLine, 0, maxLine);
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)line,
(long)std::max(0, vs.cursorCol));
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, (unsigned long)pos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETFIRSTVISIBLELINE,
(unsigned long)vs.scrollLine);
// Clamp xOffset so it doesn't exceed the current content width.
// After a rename that shrinks content, the saved offset may be stale.
int scrollW = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int vpW = m_sci->viewport() ? m_sci->viewport()->width() : 0;
int maxXOff = qMax(0, scrollW - vpW);
int xOff = qBound(0, vs.xOffset, maxXOff);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETXOFFSET, (unsigned long)xOff);
}
const LineMeta* RcxEditor::metaForLine(int line) const {
if (line >= 0 && line < m_meta.size())
return &m_meta[line];
return nullptr;
}
int RcxEditor::currentNodeIndex() const {
int line, col;
m_sci->getCursorPosition(&line, &col);
auto* lm = metaForLine(line);
return lm ? lm->nodeIdx : -1;
}
void RcxEditor::showFindBar() {
m_findBarContainer->setVisible(true);
m_findBar->setFocus();
m_findBar->selectAll();
m_findPos = 0;
}
void RcxEditor::dismissHistoryPopup() {
if (m_historyPopup)
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
void RcxEditor::dismissAllPopups() {
if (m_historyPopup) static_cast<HoverPopup*>(m_historyPopup)->dismiss();
if (m_disasmPopup) static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup) static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
}
void RcxEditor::hideFindBar() {
m_findBarContainer->setVisible(false);
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, (long)0, docLen);
m_findPos = 0;
m_sci->setFocus();
}
void RcxEditor::scrollToNodeId(uint64_t nodeId) {
for (int i = 0; i < m_meta.size(); i++) {
if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) {
m_sci->setCursorPosition(i, 0);
m_sci->ensureLineVisible(i);
return;
}
}
}
// ── Column span computation ──
ColumnSpan RcxEditor::typeSpan(const LineMeta& lm, int typeW) { return typeSpanFor(lm, typeW); }
ColumnSpan RcxEditor::nameSpan(const LineMeta& lm, int typeW, int nameW) { return nameSpanFor(lm, typeW, nameW); }
ColumnSpan RcxEditor::valueSpan(const LineMeta& lm, int lineLength, int typeW, int nameW) { return valueSpanFor(lm, lineLength, typeW, nameW); }
// For pointer-like nodes, narrow value span to just the address portion
// (before the " // " separator that precedes the symbol like "Module+0x1234").
static ColumnSpan narrowPtrValueSpan(const LineMeta& lm, const ColumnSpan& vs,
const QString& lineText) {
if (!vs.valid) return vs;
if (!isFuncPtr(lm.nodeKind)
&& lm.nodeKind != NodeKind::Pointer32
&& lm.nodeKind != NodeKind::Pointer64)
return vs;
QString valText = lineText.mid(vs.start, vs.end - vs.start);
int sep = valText.indexOf(QLatin1String(" // "));
if (sep > 0)
return {vs.start, vs.start + sep, true};
return vs;
}
// ── Multi-selection ──
QSet<int> RcxEditor::selectedNodeIndices() const {
int lineFrom, indexFrom, lineTo, indexTo;
m_sci->getSelection(&lineFrom, &indexFrom, &lineTo, &indexTo);
if (lineFrom < 0) {
int line, col;
m_sci->getCursorPosition(&line, &col);
auto* lm = metaForLine(line);
return lm && lm->nodeIdx >= 0 ? QSet<int>{lm->nodeIdx} : QSet<int>{};
}
QSet<int> result;
for (int line = lineFrom; line <= lineTo; line++) {
auto* lm = metaForLine(line);
if (lm && lm->nodeIdx >= 0) result.insert(lm->nodeIdx);
}
return result;
}
// ── Inline edit helpers ──
static QString getLineText(QsciScintilla* sci, int line) {
int len = (int)sci->SendScintilla(QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line);
if (len <= 0) return {};
QByteArray buf(len + 1, '\0');
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)line, (void*)buf.data());
QString text = QString::fromUtf8(buf.data(), len);
while (text.endsWith('\n') || text.endsWith('\r'))
text.chop(1);
return text;
}
void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta,
const QVector<QString>& lineTexts) {
static constexpr int heatIndicators[] = { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT };
for (int i = 0; i < meta.size(); i++) {
const LineMeta& lm = meta[i];
if (isSyntheticLine(lm)) continue;
int heat = lm.heatLevel;
int typeW = lm.effectiveTypeW;
int nameW = lm.effectiveNameW;
if (heat <= 0) {
// Clear any stale heat indicators from a previous frame
for (int hi : heatIndicators)
clearIndicatorLine(hi, i);
continue;
}
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
// Apply heat-level indicator to value span (narrowed for pointer-like nodes)
const QString& lineText = lineTexts[i];
ColumnSpan vs = narrowPtrValueSpan(lm,
valueSpan(lm, lineText.size(), typeW, nameW), lineText);
if (!vs.valid) continue;
fillIndicatorCols(activeInd, i, vs.start, vs.end);
// Clear the other two heat indicators on this span to avoid overlap
for (int hi : heatIndicators) {
if (hi != activeInd)
clearIndicatorLine(hi, i);
}
}
}
void RcxEditor::applySymbolColoring(const QVector<LineMeta>& meta,
const QVector<QString>& lineTexts) {
for (int i = 0; i < meta.size(); i++) {
const LineMeta& lm = meta[i];
if (!isFuncPtr(lm.nodeKind)
&& lm.nodeKind != NodeKind::Pointer32
&& lm.nodeKind != NodeKind::Pointer64)
continue;
const QString& lineText = lineTexts[i];
// Find " // " within the value region and color "// sym" portion green
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
if (!vs.valid) continue;
int searchFrom = vs.start;
int sep = lineText.indexOf(QLatin1String(" // "), searchFrom);
if (sep < 0 || sep >= vs.end) continue;
int symStart = sep + 2; // start of "// sym"
int symEnd = vs.end;
while (symEnd > symStart && lineText[symEnd - 1] == ' ') symEnd--;
if (symEnd > symStart)
fillIndicatorCols(IND_HINT_GREEN, i, symStart, symEnd);
}
}
void RcxEditor::applyBaseAddressColoring(const QVector<LineMeta>& meta) {
if (meta.isEmpty() || meta[0].lineKind != LineKind::CommandRow) return;
clearIndicatorLine(IND_BASE_ADDR, 0);
// Override lexer's green number coloring on the address with default text color
QString t = getLineText(m_sci, 0);
ColumnSpan addr = commandRowAddrSpan(t);
if (addr.valid)
fillIndicatorCols(IND_BASE_ADDR, 0, addr.start, addr.end);
}
void RcxEditor::applyCommandRowPills() {
if (m_meta.isEmpty() || m_meta[0].lineKind != LineKind::CommandRow) return;
constexpr int line = 0;
QString t = getLineText(m_sci, line);
clearIndicatorLine(IND_HEX_DIM, line);
clearIndicatorLine(IND_CLASS_NAME, line);
// Dim the [▾] type-selector chevron
ColumnSpan chevron = commandRowChevronSpan(t);
if (chevron.valid)
fillIndicatorCols(IND_HEX_DIM, line, chevron.start, chevron.end);
// Dim label text: source arrow/placeholder + its ▾ dropdown arrow
ColumnSpan srcSpan = commandRowSrcSpan(t);
if (srcSpan.valid) {
int quotePos = t.indexOf('\'', srcSpan.start);
int kindEnd = (quotePos > srcSpan.start) ? quotePos : srcSpan.end;
while (kindEnd > srcSpan.start && t[kindEnd - 1].isSpace()) kindEnd--;
if (kindEnd > srcSpan.start)
fillIndicatorCols(IND_HEX_DIM, line, srcSpan.start, kindEnd);
// Dim the source ▾ dropdown arrow to match (like struct▾)
int srcDrop = t.indexOf(QChar(0x25BE));
int rootStart = commandRowRootStart(t);
if (srcDrop >= 0 && (rootStart < 0 || srcDrop < rootStart))
fillIndicatorCols(IND_HEX_DIM, line, srcDrop, srcDrop + 1);
}
// Dim base address to match source/struct grey
ColumnSpan addrSpan = commandRowAddrSpan(t);
if (addrSpan.valid)
fillIndicatorCols(IND_HEX_DIM, line, addrSpan.start, addrSpan.end);
// Root class styling (type dim + class-name teal, no underline)
ColumnSpan rt = commandRowRootTypeSpan(t);
if (rt.valid) {
fillIndicatorCols(IND_HEX_DIM, line, rt.start, rt.end);
int drop = t.indexOf(QChar(0x25BE), rt.start);
if (drop >= 0)
fillIndicatorCols(IND_HEX_DIM, line, drop, qMin(drop + 2, t.size()));
}
ColumnSpan rn = commandRowRootNameSpan(t);
if (rn.valid) {
fillIndicatorCols(IND_CLASS_NAME, line, rn.start, rn.end);
}
// Dim trailing opening brace to match the rest of the command row grey
for (int i = t.size() - 1; i >= 0; --i) {
if (t[i] == ' ' || t[i] == '\t') continue;
if (t[i] == '{')
fillIndicatorCols(IND_HEX_DIM, line, i, i + 1);
break;
}
}
// ── Shared inline-edit shutdown ──
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
|| (m_editState.hexOverwrite && m_editState.target == EditTarget::Name)) {
setEditComment({}); // Clear to spaces
m_sci->markerDelete(m_editState.line, M_ERR);
}
EndEditInfo info{m_editState.nodeIdx, m_editState.subLine, m_editState.target};
m_editState.active = false;
m_sci->setReadOnly(true);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 0);
// Switch back to Arrow cursor (widget-local, doesn't fight splitters/menus)
m_sci->viewport()->setCursor(Qt::ArrowCursor);
// Disable selection rendering again
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELFORE, (long)0, (long)0);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELBACK, (long)0, (long)0);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, (long)1);
m_sci->SendScintilla(QsciScintillaBase::SCI_EMPTYUNDOBUFFER);
return info;
}
// ── Span helpers ──
// Name span for struct/array headers - uses column-based positioning
// Format: [fold][indent][type col][sep][name col][sep][suffix]
static ColumnSpan headerNameSpan(const LineMeta& lm, const QString& lineText) {
if (lm.lineKind != LineKind::Header) return {};
int ind = kFoldCol + lm.depth * 3;
int typeW = lm.effectiveTypeW;
int nameStart = ind + typeW + kSepWidth;
if (nameStart >= lineText.size()) return {};
// Name ends before " {" suffix (expanded) or at line end (collapsed)
int nameEnd = lineText.size();
if (lineText.endsWith(QStringLiteral(" {")))
nameEnd = lineText.size() - 2;
if (nameEnd <= nameStart) return {};
// Don't allow editing array element names like "[0]", "[1]", etc.
QString name = lineText.mid(nameStart, nameEnd - nameStart).trimmed();
if (name.isEmpty()) return {};
if (name.startsWith('[') && name.endsWith(']'))
return {};
return {nameStart, nameEnd, true};
}
// Type name span for struct headers (not arrays)
// Named structs format as: "_MMPTE OriginalPte {" (type column = just the name)
// Anonymous structs format as: "union {" or "struct {" (no clickable type)
static ColumnSpan headerTypeNameSpan(const LineMeta& lm, const QString& lineText) {
if (lm.lineKind != LineKind::Header) return {};
if (lm.isArrayHeader) return {};
int ind = kFoldCol + lm.depth * 3;
int typeW = lm.effectiveTypeW;
int typeEnd = ind + typeW;
if (typeEnd > lineText.size()) typeEnd = lineText.size();
QString typeCol = lineText.mid(ind, typeEnd - ind).trimmed();
if (typeCol.isEmpty()) return {};
// Anonymous structs use bare keywords — not clickable
static const QStringList kKeywords = {
QStringLiteral("struct"), QStringLiteral("union"), QStringLiteral("class")
};
if (kKeywords.contains(typeCol)) return {};
// Static field headers: "static hex64 target {" — skip "static " prefix
if (lm.isStaticLine) {
int cursor = ind;
while (cursor < typeEnd && lineText[cursor] == ' ') cursor++;
if (lineText.mid(cursor, 7) == QLatin1String("static "))
cursor += 7;
while (cursor < typeEnd && lineText[cursor] == ' ') cursor++;
int tStart = cursor;
while (cursor < typeEnd && lineText[cursor] != ' ') cursor++;
if (cursor > tStart)
return {tStart, cursor, true};
return {};
}
// Named struct: entire type column is the type name (e.g. "_MMPTE")
// Find the actual text bounds within the padded column
int start = ind;
while (start < typeEnd && lineText[start] == ' ') start++;
int end = start;
while (end < typeEnd && lineText[end] != ' ') end++;
if (end <= start) return {};
return {start, end, true};
}
// Type span for array headers: "int32_t[10]" in "int32_t[10] positions {"
static ColumnSpan arrayHeaderTypeSpan(const LineMeta& lm, const QString& lineText) {
if (lm.lineKind != LineKind::Header || !lm.isArrayHeader) return {};
int ind = kFoldCol + lm.depth * 3;
int typeEnd = lineText.indexOf(' ', ind);
if (typeEnd <= ind) return {};
return {ind, typeEnd, true};
}
RcxEditor::NormalizedSpan RcxEditor::normalizeSpan(
const ColumnSpan& raw, const QString& lineText,
EditTarget target, bool skipPrefixes) const
{
if (!raw.valid) return {};
int textLen = lineText.size();
if (raw.start >= textLen) return {};
int start = raw.start;
int end = qMin(raw.end, textLen);
if (end <= start) return {};
if (skipPrefixes && target == EditTarget::Value) {
QString spanText = lineText.mid(start, end - start);
int arrow = spanText.indexOf(QStringLiteral("->"));
if (arrow >= 0) {
int i = arrow + 2;
while (i < spanText.size() && spanText[i].isSpace()) i++;
start += i;
} else {
int eq = spanText.indexOf('=');
if (eq >= 0 && eq <= 3) {
int i = eq + 1;
while (i < spanText.size() && spanText[i].isSpace()) i++;
start += i;
}
}
if (start >= end) return {};
}
QString inner = lineText.mid(start, end - start);
int lead = 0;
while (lead < inner.size() && inner[lead].isSpace()) lead++;
int trail = inner.size();
while (trail > lead && inner[trail - 1].isSpace()) trail--;
if (trail <= lead) return {};
return {start + lead, start + trail, true};
}
bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
NormalizedSpan& out, QString* lineTextOut) const {
const LineMeta* lm = metaForLine(line);
if (!lm) return false;
// CommandRow: Source / BaseAddress / Root class (type+name) editing
if (lm->lineKind == LineKind::CommandRow) {
if (t != EditTarget::BaseAddress && t != EditTarget::Source
&& t != EditTarget::RootClassType && t != EditTarget::RootClassName
&& t != EditTarget::TypeSelector) return false;
QString lineText = getLineText(m_sci, line);
ColumnSpan s;
if (t == EditTarget::TypeSelector) s = commandRowChevronSpan(lineText);
else if (t == EditTarget::Source) s = commandRowSrcSpan(lineText);
else if (t == EditTarget::BaseAddress) s = commandRowAddrSpan(lineText);
else if (t == EditTarget::RootClassType) s = commandRowRootTypeSpan(lineText);
else s = commandRowRootNameSpan(lineText);
out = normalizeSpan(s, lineText, t, /*skipPrefixes=*/(t == EditTarget::BaseAddress));
if (lineTextOut) *lineTextOut = lineText;
return out.valid;
}
if (lm->nodeIdx < 0) 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)
if ((t == EditTarget::Name || t == EditTarget::Value) && isHexNode(lm->nodeKind) && !lm->isStaticLine)
return false;
QString lineText = getLineText(m_sci, line);
int textLen = lineText.size();
// Use per-line effective widths (set during compose based on containing scope)
int typeW = lm->effectiveTypeW;
int nameW = lm->effectiveNameW;
ColumnSpan s;
switch (t) {
case EditTarget::Type: s = typeSpan(*lm, typeW); break;
case EditTarget::Name: s = nameSpan(*lm, typeW, nameW); break;
case EditTarget::Value: s = narrowPtrValueSpan(*lm,
valueSpan(*lm, textLen, typeW, nameW), lineText); break;
case EditTarget::BaseAddress: break; // No longer on header lines
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::StaticExpr:
if (lm->isStaticLine)
s = staticExprSpanFor(*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);
// Member lines: override Name/Value spans
if (!s.valid && lm->isMemberLine) {
if (t == EditTarget::Name) s = memberNameSpanFor(*lm, lineText);
if (t == EditTarget::Value) s = memberValueSpanFor(*lm, lineText);
}
out = normalizeSpan(s, lineText, t, /*skipPrefixes=*/true);
if (lineTextOut) *lineTextOut = lineText;
return out.valid;
}
// ── Point → line/col/nodeId resolution ──
RcxEditor::HitInfo RcxEditor::hitTest(const QPoint& vp) const {
HitInfo h;
// Try precise position first (works when cursor is over actual text)
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMPOINTCLOSE,
(unsigned long)vp.x(), (long)vp.y());
if (pos >= 0) {
h.line = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_LINEFROMPOSITION, (unsigned long)pos);
h.col = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_GETCOLUMN, (unsigned long)pos);
} else {
// Fallback: calculate line from Y coordinate (for empty space past text)
int firstVisible = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_GETFIRSTVISIBLELINE);
int lineHeight = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_TEXTHEIGHT, 0);
if (lineHeight > 0)
h.line = firstVisible + vp.y() / lineHeight;
}
if (h.line >= 0 && h.line < m_meta.size()) {
h.nodeId = m_meta[h.line].nodeId;
h.inFoldCol = (h.col >= 0 && h.col < kFoldCol + 1 && m_meta[h.line].foldHead);
}
return h;
}
// ── Double-click hit test ──
static bool hitTestTarget(QsciScintilla* sci,
const QVector<LineMeta>& meta,
const QPoint& viewportPos,
int& outLine, int& outCol, EditTarget& outTarget)
{
long pos = sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMPOINTCLOSE,
(unsigned long)viewportPos.x(), (long)viewportPos.y());
if (pos < 0) return false;
int line = (int)sci->SendScintilla(QsciScintillaBase::SCI_LINEFROMPOSITION,
(unsigned long)pos);
int col = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETCOLUMN,
(unsigned long)pos);
outCol = col;
if (line < 0 || line >= meta.size()) return false;
QString lineText = getLineText(sci, line);
int textLen = lineText.size();
const LineMeta& lm = meta[line];
if (lm.lineKind == LineKind::ArrayElementSeparator) return false;
auto inSpan = [&](const ColumnSpan& s) {
return s.valid && col >= s.start && col < s.end;
};
// CommandRow: interactive chevron/SRC/ADDR + root class (type+name)
if (lm.lineKind == LineKind::CommandRow) {
ColumnSpan chevron = commandRowChevronSpan(lineText);
if (inSpan(chevron)) { outTarget = EditTarget::TypeSelector; outLine = line; return true; }
ColumnSpan ss = commandRowSrcSpan(lineText);
if (inSpan(ss)) { outTarget = EditTarget::Source; outLine = line; return true; }
ColumnSpan as = commandRowAddrSpan(lineText);
if (inSpan(as)) { outTarget = EditTarget::BaseAddress; outLine = line; return true; }
// RootClassType is no longer clickable — use right-click to convert
ColumnSpan rns = commandRowRootNameSpan(lineText);
if (inSpan(rns)) { outTarget = EditTarget::RootClassName; outLine = line; return true; }
return false;
}
// Use per-line effective widths from LineMeta
int typeW = lm.effectiveTypeW;
int nameW = lm.effectiveNameW;
ColumnSpan ts = RcxEditor::typeSpan(lm, typeW);
ColumnSpan ns = RcxEditor::nameSpan(lm, typeW, nameW);
ColumnSpan vs = narrowPtrValueSpan(lm,
RcxEditor::valueSpan(lm, textLen, typeW, nameW), lineText);
// 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
// Count click area includes brackets [N] so clicking [ or ] edits the count
if (lm.isArrayHeader) {
ColumnSpan elemCountClick = arrayElemCountClickSpanFor(lm, lineText);
ColumnSpan elemType = arrayElemTypeSpanFor(lm, lineText);
if (inSpan(elemCountClick)) { 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);
if (!ts.valid)
ts = headerTypeNameSpan(lm, lineText);
}
if (!ns.valid)
ns = headerNameSpan(lm, lineText);
// Member lines: use name/value spans from line text (no type span)
if (lm.isMemberLine) {
ns = memberNameSpanFor(lm, lineText);
vs = memberValueSpanFor(lm, lineText);
}
if (inSpan(ts)) outTarget = EditTarget::Type;
else if (inSpan(ns)) outTarget = EditTarget::Name;
else if (inSpan(vs)) outTarget = EditTarget::Value;
else return false;
// Array headers: redirect generic Type hit to ArrayElementType (uses popup, not inline edit)
if (lm.isArrayHeader && outTarget == EditTarget::Type) {
outTarget = EditTarget::ArrayElementType;
outLine = line;
return true;
}
// Array element lines: type/name click opens element type picker on the parent array header
if (lm.isArrayElement && (outTarget == EditTarget::Type || outTarget == EditTarget::Name)) {
outTarget = EditTarget::ArrayElementType;
// Find the array header line (previous line with isArrayHeader and same nodeIdx)
for (int l = line - 1; l >= 0; l--) {
if (l >= meta.size()) continue;
const LineMeta& hdr = meta[l];
if (hdr.isArrayHeader && hdr.nodeIdx == lm.nodeIdx) {
outLine = l;
return true;
}
}
return false;
}
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((outTarget == EditTarget::Name || outTarget == EditTarget::Value) && isHexNode(lm.nodeKind))
return false;
outLine = line;
return true;
}
// ── Event filter ──
bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
if (obj == m_sci && event->type() == QEvent::KeyPress) {
auto* ke = static_cast<QKeyEvent*>(event);
if (ke->matches(QKeySequence::Find)) {
showFindBar();
return true;
}
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) {
auto* me = static_cast<QMouseEvent*>(event);
auto h = hitTest(me->pos());
if (h.line == m_editState.line) {
int editEnd = editEndCol();
bool insideTrimmed = (h.col >= m_editState.spanStart && h.col <= editEnd);
if (insideTrimmed)
return false; // inside trimmed text: let Scintilla position cursor
// Check raw span (full column width) - click in padding moves cursor to end
const LineMeta* lm = metaForLine(m_editState.line);
if (lm) {
QString lineText = getLineText(m_sci, h.line);
// Use per-line effective widths
int typeW = lm->effectiveTypeW;
int nameW = lm->effectiveNameW;
ColumnSpan raw;
switch (m_editState.target) {
case EditTarget::Type: raw = typeSpan(*lm, typeW); break;
case EditTarget::Name: raw = nameSpan(*lm, typeW, nameW); break;
case EditTarget::Value: raw = valueSpan(*lm, lineText.size(), typeW, nameW); break;
case EditTarget::BaseAddress: raw = commandRowAddrSpan(lineText); break;
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
long endPos = posFromCol(m_sci, m_editState.line, editEnd);
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, endPos);
return true; // consume event
}
}
}
commitInlineEdit();
m_currentSelIds.clear();
return true; // consume — metadata was recomposed; stale coords unsafe
}
// Single-click on fold column (" - " / " + ") toggles fold
// Other left-clicks emit nodeClicked for selection
if (obj == m_sci->viewport() && !m_editState.active
&& event->type() == QEvent::MouseButtonPress) {
auto* me = static_cast<QMouseEvent*>(event);
if (me->button() == Qt::LeftButton) {
// Sync hover to click position (prevents hover/selection desync)
m_lastHoverPos = me->pos();
m_hoverInside = true;
auto h = hitTest(me->pos());
uint64_t newHoverId = (h.line >= 0) ? h.nodeId : 0;
if (newHoverId != m_hoveredNodeId || h.line != m_hoveredLine) {
m_hoveredNodeId = newHoverId;
m_hoveredLine = h.line;
applyHoverHighlight();
}
if (h.inFoldCol) {
emit marginClicked(0, h.line, me->modifiers());
return true;
}
// Footer buttons: +10h/+100h/+1000h, +10 (enum), Trim
if (h.line >= 0 && h.line < m_meta.size()
&& m_meta[h.line].lineKind == LineKind::Footer) {
QString ft = getLineText(m_sci, h.line);
uint64_t nid = m_meta[h.line].nodeId;
// Struct: +1000h (0x1000 = 4096 bytes)
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
if (p1000 >= 0 && h.col >= p1000 && h.col < p1000 + 6) {
emit appendBytesRequested(nid, 0x1000);
return true;
}
// Struct: +100h (0x100 = 256 bytes)
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1 && h.col >= p100 && h.col < p100 + 5) {
emit appendBytesRequested(nid, 0x100);
return true;
}
// Struct: +10h (0x10 = 16 bytes)
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000 && h.col >= p10 && h.col < p10 + 4) {
emit appendBytesRequested(nid, 0x10);
return true;
}
// Enum: +10 (10 members)
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000
&& h.col >= add10Start && h.col < add10Start + 3) {
emit appendEnumMembersRequested(nid, 10);
return true;
}
int trimStart = ft.indexOf(QStringLiteral("Trim"));
if (trimStart >= 0 && h.col >= trimStart && h.col < trimStart + 4) {
emit trimHexRequested(nid);
return true;
}
}
// CommandRow: try chevron/ADDR edit or consume
if (h.nodeId == kCommandRowId) {
int tLine, tCol; EditTarget t;
if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, tCol, t)) {
if (t == EditTarget::TypeSelector)
emit typeSelectorRequested();
else
beginInlineEdit(t, tLine, tCol);
}
return true; // consume all CommandRow clicks
}
if (h.nodeId != 0) {
bool alreadySelected = m_currentSelIds.contains(h.nodeId);
bool plain = !(me->modifiers() & (Qt::ControlModifier | Qt::ShiftModifier));
// Single-click on editable token of already-selected node → edit
int tLine, tCol; EditTarget t;
if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, tCol, t)) {
if (alreadySelected && plain) {
m_pendingClickNodeId = 0;
return beginInlineEdit(t, tLine, tCol);
}
}
m_dragging = true;
m_dragStarted = false; // require threshold before extending
m_dragStartPos = me->pos();
m_dragLastLine = h.line;
m_dragInitMods = me->modifiers();
bool multi = m_currentSelIds.size() > 1;
if (alreadySelected && multi && plain) {
// Defer: might be start of double-click-to-edit
m_pendingClickNodeId = h.nodeId;
m_pendingClickLine = h.line;
m_pendingClickMods = me->modifiers();
} else {
emit nodeClicked(h.line, h.nodeId, me->modifiers());
m_pendingClickNodeId = 0;
}
}
return true; // consume ALL left-clicks (prevent QScintilla caret/cursor)
}
}
// Drag-select: extend selection as mouse moves with button held
// Requires minimum drag distance to prevent accidental micro-drag selection
if (obj == m_sci->viewport() && !m_editState.active
&& event->type() == QEvent::MouseMove && m_dragging) {
auto* me = static_cast<QMouseEvent*>(event);
if (me->buttons() & Qt::LeftButton) {
// Check drag threshold (8 pixels) before starting drag-selection
if (!m_dragStarted) {
int dy = me->pos().y() - m_dragStartPos.y();
if (qAbs(dy) < 8)
return true; // not yet a drag, but still consume (don't let Scintilla handle)
m_dragStarted = true;
}
// Flush deferred click before extending drag
if (m_pendingClickNodeId != 0) {
emit nodeClicked(m_pendingClickLine, m_pendingClickNodeId,
m_pendingClickMods);
m_pendingClickNodeId = 0;
}
auto h = hitTest(me->pos());
if (h.line >= 0 && h.line != m_dragLastLine && h.nodeId != 0) {
emit nodeClicked(h.line, h.nodeId, m_dragInitMods | Qt::ShiftModifier);
m_dragLastLine = h.line;
}
} else {
m_dragging = false;
m_dragStarted = false;
}
}
if (obj == m_sci->viewport() && event->type() == QEvent::MouseButtonRelease) {
m_dragging = false;
m_dragStarted = false;
if (m_pendingClickNodeId != 0) {
emit nodeClicked(m_pendingClickLine, m_pendingClickNodeId,
m_pendingClickMods);
m_pendingClickNodeId = 0;
}
return true; // consume release (prevent QScintilla from acting on it)
}
// Double-click on offset margin → toggle absolute/relative
if (obj == m_sci->viewport() && event->type() == QEvent::MouseButtonDblClick) {
auto* me = static_cast<QMouseEvent*>(event);
int margin0Width = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_GETMARGINWIDTHN, 0UL, 0L);
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
if ((int)me->position().x() < margin0Width) {
#else
if ((int)me->pos().x() < margin0Width) {
#endif
m_relativeOffsets = !m_relativeOffsets;
reformatMargins();
emit relativeOffsetsChanged(m_relativeOffsets);
return true;
}
}
// Double-click during edit mode: select entire editable text
if (obj == m_sci->viewport() && m_editState.active
&& event->type() == QEvent::MouseButtonDblClick) {
m_sci->setSelection(m_editState.line, m_editState.spanStart,
m_editState.line, editEndCol());
return true;
}
if (obj == m_sci->viewport() && !m_editState.active
&& event->type() == QEvent::MouseButtonDblClick) {
auto* me = static_cast<QMouseEvent*>(event);
int line, tCol; EditTarget t;
if (hitTestTarget(m_sci, m_meta, me->pos(), line, tCol, 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, tCol);
}
return true; // consume even on miss (prevent QScintilla word-select)
}
if (obj == m_sci && event->type() == QEvent::FocusOut) {
auto* fe = static_cast<QFocusEvent*>(event);
// Commit active edit on focus loss (click-away = save)
// Deferred so autocomplete popup has time to register as active
if (m_editState.active && fe->reason() != Qt::PopupFocusReason) {
QTimer::singleShot(0, this, [this]() {
if (m_editState.active && !m_sci->hasFocus()
&& !m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCACTIVE))
commitInlineEdit();
});
}
// Clear editable indicators when editor loses focus
clearIndicatorLine(IND_EDITABLE, m_hintLine);
m_hintLine = -1;
}
if (obj == m_sci && event->type() == QEvent::FocusIn) {
int line, col;
m_sci->getCursorPosition(&line, &col);
updateEditableIndicators(line);
}
// Track mouse position for cursor updates (both edit and non-edit mode)
if (obj == m_sci->viewport()) {
// Ignore synthetic Leave from setText() during document refresh
if (m_applyingDocument && event->type() == QEvent::Leave)
return true;
if (event->type() == QEvent::MouseMove) {
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
m_hoverInside = true;
} else if (event->type() == QEvent::Leave) {
// Don't dismiss if cursor moved onto one of our own popups
QPoint globalCursor = QCursor::pos();
bool onPopup = false;
if (m_historyPopup && m_historyPopup->isVisible()
&& m_historyPopup->geometry().contains(globalCursor))
onPopup = true;
if (m_disasmPopup && m_disasmPopup->isVisible()
&& m_disasmPopup->geometry().contains(globalCursor))
onPopup = true;
if (m_structPreviewPopup && m_structPreviewPopup->isVisible()
&& m_structPreviewPopup->geometry().contains(globalCursor))
onPopup = true;
if (!onPopup) {
m_hoverInside = false;
if (!m_editState.active) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
}
}
} else if (event->type() == QEvent::Wheel) {
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
m_hoverInside = m_sci->viewport()->rect().contains(m_lastHoverPos);
}
// Resolve hovered nodeId on move/wheel (non-edit mode only)
// Guard: skip during applyDocument — m_nodeLineIndex may be stale
if (!m_editState.active && !m_applyingDocument &&
(event->type() == QEvent::MouseMove || event->type() == QEvent::Wheel)) {
auto h = hitTest(m_lastHoverPos);
uint64_t newHoverId = (m_hoverInside && h.line >= 0) ? h.nodeId : 0;
int newHoverLine = (m_hoverInside && h.line >= 0) ? h.line : -1;
if (newHoverId != m_hoveredNodeId || newHoverLine != m_hoveredLine) {
m_hoveredNodeId = newHoverId;
m_hoveredLine = newHoverLine;
applyHoverHighlight();
}
}
// Update cursor on move/leave/wheel (both edit and non-edit mode)
if (event->type() == QEvent::MouseMove
|| event->type() == QEvent::Leave
|| event->type() == QEvent::Wheel)
applyHoverCursor();
// Consume MouseMove in non-edit mode so QScintilla's internal handler
// doesn't override our cursor (it resets to Arrow for read-only widgets)
if (!m_editState.active && event->type() == QEvent::MouseMove)
return true;
}
return QWidget::eventFilter(obj, event);
}
// ── Normal mode key handling ──
bool RcxEditor::handleNormalKey(QKeyEvent* ke) {
switch (ke->key()) {
case Qt::Key_F2:
return beginInlineEdit(EditTarget::Name);
case Qt::Key_T:
if (ke->modifiers() == Qt::NoModifier)
return beginInlineEdit(EditTarget::Type);
return false;
case Qt::Key_Return:
case Qt::Key_Enter:
return beginInlineEdit(EditTarget::Value);
case Qt::Key_Insert:
if (ke->modifiers() & Qt::ShiftModifier)
emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex32);
else
emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex64);
return true;
case Qt::Key_Tab: {
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 < 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;
}
default:
return false;
}
}
// ── 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
switch (ke->key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
commitInlineEdit();
return true;
case Qt::Key_Tab:
m_lastTabTarget = m_editState.target;
commitInlineEdit();
return true;
case Qt::Key_Escape:
cancelInlineEdit();
return true;
case Qt::Key_Up:
case Qt::Key_Down:
case Qt::Key_PageUp:
case Qt::Key_PageDown:
return true; // block line navigation
case Qt::Key_Delete: {
int line, col;
m_sci->getCursorPosition(&line, &col);
if (col >= editEndCol()) return true; // block at end
return false; // allow delete within span
}
case Qt::Key_Left:
case Qt::Key_Backspace: {
int line, col;
m_sci->getCursorPosition(&line, &col);
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 there's an active selection, collapse it to the left end (Left only, not Backspace)
if (ke->key() == Qt::Key_Left) {
int sL, sC, eL, eC;
m_sci->getSelection(&sL, &sC, &eL, &eC);
if (sL >= 0 && (sL != eL || sC != eC)) {
int leftEnd = qMax(qMin(sC, eC), minCol);
m_sci->setCursorPosition(m_editState.line, leftEnd);
return true;
}
}
if (col <= minCol) return true;
return false;
}
case Qt::Key_Right: {
int line, col;
m_sci->getCursorPosition(&line, &col);
// If there's an active selection, collapse it to the right end first
int sL, sC, eL, eC;
m_sci->getSelection(&sL, &sC, &eL, &eC);
if (sL >= 0 && (sL != eL || sC != eC)) {
int rightEnd = qMin(qMax(sC, eC), editEndCol());
m_sci->setCursorPosition(m_editState.line, rightEnd);
return true;
}
if (col >= editEndCol()) return true; // block past end
return false;
}
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;
case Qt::Key_V:
if (ke->modifiers() & Qt::ControlModifier) {
// Sanitized paste: strip newlines (and backticks for base addresses)
QString clip = QApplication::clipboard()->text();
clip.remove('\n');
clip.remove('\r');
if (m_editState.target == EditTarget::BaseAddress)
clip.remove('`');
if (!clip.isEmpty()) {
QByteArray utf8 = clip.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL,
(uintptr_t)0, utf8.constData());
}
return true;
}
return false;
default:
return false;
}
}
// ── 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) {
if (target == EditTarget::TypeSelector) return false; // handled by popup, not inline edit
// Type, array element type and pointer target: handled by TypeSelectorPopup, not inline edit
if (target == EditTarget::Type || target == EditTarget::ArrayElementType || target == EditTarget::PointerTarget) {
if (line < 0) {
int c;
m_sci->getCursorPosition(&line, &c);
}
auto* lm = metaForLine(line);
if (!lm) return false;
// Reject lines that don't support type editing
if (lm->nodeIdx < 0) return false; // CommandRow etc.
if (lm->lineKind == LineKind::Footer) return false;
// Position popup at the type column start
ColumnSpan ts = typeSpan(*lm);
long typePos = posFromCol(m_sci, line, ts.valid ? ts.start : 0);
int lineH = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT, (unsigned long)line);
int x = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION, (unsigned long)0, typePos);
int y = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION, (unsigned long)0, typePos);
QPoint pos = m_sci->viewport()->mapToGlobal(QPoint(x, y + lineH));
emit typePickerRequested(target, lm->nodeIdx, pos);
return true;
}
if (m_editState.active) return false;
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
// Dismiss hover popups so they get recreated with Set buttons once edit starts
dismissAllPopups();
// Clear editable-token color hints (de-emphasize non-active tokens)
clearIndicatorLine(IND_EDITABLE, m_hintLine);
m_hintLine = -1;
if (line >= 0) {
m_sci->setCursorPosition(line, col >= 0 ? col : 0);
}
if (col < 0) {
m_sci->getCursorPosition(&line, &col);
}
auto* lm = metaForLine(line);
if (!lm) return false;
// Allow nodeIdx=-1 only for CommandRow editing (command bar)
if (lm->nodeIdx < 0 && !(lm->lineKind == LineKind::CommandRow &&
(target == EditTarget::BaseAddress || target == EditTarget::Source
|| target == EditTarget::RootClassType || target == EditTarget::RootClassName)))
return false;
// 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 (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);
int vecComponent = 0; // which vector/matrix component
// Helper: parse comma-separated components, narrow span to clicked one
auto narrowToComponent = [&](const QString& inner, int innerAbsStart) {
QVector<int> compStarts, compEnds;
for (int i = 0; i < inner.size(); i++) {
if (inner[i] == ',') {
compEnds.append(i);
int next = i + 1;
while (next < inner.size() && inner[next] == ' ') next++;
compStarts.append(next);
}
}
compStarts.prepend(0);
compEnds.append(inner.size());
int relCol = col - innerAbsStart;
vecComponent = 0;
for (int i = 0; i < compStarts.size(); i++) {
if (relCol >= compStarts[i] && (i == compStarts.size() - 1 || relCol < compStarts[i + 1]))
{ vecComponent = i; break; }
}
if (vecComponent >= compStarts.size()) vecComponent = compStarts.size() - 1;
int cStart = innerAbsStart + compStarts[vecComponent];
int cEnd = innerAbsStart + compEnds[vecComponent];
while (cEnd > cStart && lineText[cEnd - 1] == ' ') cEnd--;
norm.start = cStart;
norm.end = cEnd;
trimmed = lineText.mid(norm.start, norm.end - norm.start);
};
// For vector value editing: narrow span to the clicked component
if (target == EditTarget::Value && isVectorKind(lm->nodeKind)) {
narrowToComponent(trimmed, norm.start);
}
// For Mat4x4 value editing: skip "rowN [...]" and narrow to clicked component
if (target == EditTarget::Value && isMatrixKind(lm->nodeKind)) {
int bracketOpen = trimmed.indexOf('[');
int bracketClose = trimmed.lastIndexOf(']');
if (bracketOpen < 0 || bracketClose <= bracketOpen)
return false;
QString inner = trimmed.mid(bracketOpen + 1, bracketClose - bracketOpen - 1);
int innerAbsStart = norm.start + bracketOpen + 1;
narrowToComponent(inner, innerAbsStart);
}
m_editState.active = true;
m_editState.line = line;
m_editState.nodeIdx = lm->nodeIdx;
m_editState.subLine = lm->subLine;
m_editState.target = target;
m_editState.spanStart = norm.start;
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;
}
if (isMatrixKind(lm->nodeKind)) {
m_editState.subLine = lm->subLine * 4 + vecComponent; // flat index 0-15
m_editState.editKind = NodeKind::Float;
}
// 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 || (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
} else if (target == EditTarget::BaseAddress) {
m_editState.commentCol = (int)lineText.size() + 2; // after full command row content
} else {
m_editState.commentCol = -1;
}
// Keep undo collection enabled during inline edit so CellBuffer::DeleteChars
// returns valid text pointers (collectingUndo=false returns nullptr, which
// crashes QsciAccessibleBase::textDeleted). We clear the buffer on edit end.
m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, (long)1);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1);
m_sci->setReadOnly(false);
// 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
|| (isHexEdit && target == EditTarget::Name))
&& m_editState.commentCol >= 0) {
int commentStart = m_editState.commentCol;
int commentWidth = (target == EditTarget::BaseAddress) ? 60 : kColComment;
int neededLen = commentStart + commentWidth;
int currentLen = (int)lineText.size();
if (currentLen < neededLen) {
int extend = neededLen - currentLen;
long lineEndPos = posFromCol(m_sci, line, currentLen);
QString pad(extend, ' ');
QByteArray padUtf8 = pad.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, lineEndPos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, lineEndPos);
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET,
(uintptr_t)padUtf8.size(), padUtf8.constData());
m_editState.linelenAfterReplace += extend;
}
}
// Switch to I-beam for editing (skip for picker-based targets)
if (target != EditTarget::Type && target != EditTarget::Source
&& target != EditTarget::ArrayElementType && target != EditTarget::PointerTarget
&& target != EditTarget::RootClassType) {
m_sci->viewport()->setCursor(Qt::IBeamCursor);
}
// Re-enable selection rendering for inline edit (skip for picker-based targets)
bool isPicker = (target == EditTarget::Type || target == EditTarget::Source
|| target == EditTarget::ArrayElementType
|| target == EditTarget::PointerTarget
|| target == EditTarget::RootClassType);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELFORE, (long)0, (long)0);
if (!isPicker)
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELBACK, (long)1,
ThemeManager::instance().current().selection);
// Use correct UTF-8 position conversion (not lineStart + col!)
m_editState.posStart = posFromCol(m_sci, line, norm.start);
m_editState.posEnd = posFromCol(m_sci, line, norm.end);
// For Value/BaseAddress: skip 0x prefix in selection (select only the number)
long selStart = m_editState.posStart;
if ((target == EditTarget::Value || target == EditTarget::BaseAddress) &&
trimmed.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive)) {
selStart = m_editState.posStart + 2; // Skip "0x"
}
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) {
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
// and exit early above (never reach here).
if (target == EditTarget::Source)
QTimer::singleShot(0, this, &RcxEditor::showSourcePicker);
// RootClassType is no longer editable via click — use right-click conversion instead
// Refresh hover cursor so value history popup appears with Set buttons immediately
if (target == EditTarget::Value)
QTimer::singleShot(0, this, &RcxEditor::applyHoverCursor);
return true;
}
int RcxEditor::editEndCol() const {
QString lineText = getLineText(m_sci, m_editState.line);
int delta = lineText.size() - m_editState.linelenAfterReplace;
return m_editState.spanStart + m_editState.original.size() + delta;
}
void RcxEditor::clampEditSelection() {
if (!m_editState.active) return;
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);
int editEnd = editEndCol();
bool isCursor = (selStartLine == selEndLine && selStartCol == selEndCol);
// Don't fight cursor positioning - only clamp actual selections
if (isCursor) {
m_clampingSelection = false;
return;
}
// Actual selection - clamp both ends to edit span
bool clamped = false;
// Force to edit line
if (selStartLine != m_editState.line || selEndLine != m_editState.line) {
m_sci->setSelection(m_editState.line, m_editState.spanStart,
m_editState.line, editEnd);
m_clampingSelection = false;
return;
}
if (selStartCol < m_editState.spanStart) { selStartCol = m_editState.spanStart; clamped = true; }
if (selEndCol < m_editState.spanStart) { selEndCol = m_editState.spanStart; clamped = true; }
if (selStartCol > editEnd) { selStartCol = editEnd; clamped = true; }
if (selEndCol > editEnd) { selEndCol = editEnd; clamped = true; }
if (clamped)
m_sci->setSelection(selStartLine, selStartCol, selEndLine, selEndCol);
m_clampingSelection = false;
}
// ── Commit inline edit ──
void RcxEditor::commitInlineEdit() {
if (!m_editState.active) return;
QString lineText = getLineText(m_sci, m_editState.line);
int currentLen = lineText.size();
int delta = currentLen - m_editState.linelenAfterReplace;
int editedLen = m_editState.original.size() + delta;
QString editedText;
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())
editedText = m_editState.original;
// Grab resolved address from LineMeta before endInlineEdit clears state
const LineMeta* lm = metaForLine(m_editState.line);
uint64_t addr = lm ? lm->offsetAddr : 0;
auto info = endInlineEdit();
emit inlineEditCommitted(info.nodeIdx, info.subLine, info.target, editedText, addr);
}
// ── Cancel inline edit ──
void RcxEditor::cancelInlineEdit() {
if (!m_editState.active) return;
endInlineEdit();
emit inlineEditCancelled();
}
// ── Type picker (user list) ──
void RcxEditor::showTypeAutocomplete() {
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();
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());
// Position cursor at start
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, m_editState.posStart);
showTypeListFiltered(QString()); // Show full list initially
}
void RcxEditor::showTypeListFiltered(const QString& filter) {
if (!m_editState.active ||
(m_editState.target != EditTarget::Type && m_editState.target != EditTarget::ArrayElementType))
return;
// Combine native types with custom (struct) type names
QStringList all = allTypeNamesForUI();
for (const QString& ct : m_customTypeNames) {
if (!all.contains(ct))
all << ct;
}
all.sort(Qt::CaseInsensitive);
// Filter by prefix
QStringList filtered;
for (const QString& t : all) {
if (filter.isEmpty() || t.startsWith(filter, Qt::CaseInsensitive))
filtered << t;
}
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('\n').toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n');
m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW,
(uintptr_t)1, list.constData());
// Force Arrow cursor immediately (don't wait for mouse move)
m_sci->viewport()->setCursor(Qt::ArrowCursor);
}
void RcxEditor::showSourcePicker() {
if (!m_editState.active || m_editState.target != EditTarget::Source)
return;
QMenu menu;
QFont menuFont = editorFont();
int zoom = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETZOOM);
menuFont.setPointSize(menuFont.pointSize() + zoom);
menu.setFont(menuFont);
menu.addAction("File");
// Add all registered providers from global registry
const auto& providers = ProviderRegistry::instance().providers();
for (const auto& provider : providers)
menu.addAction(provider.name);
// Saved sources below separator (with checkmarks)
if (!m_savedSourceDisplay.isEmpty()) {
menu.addSeparator();
for (int i = 0; i < m_savedSourceDisplay.size(); i++) {
auto* act = menu.addAction(m_savedSourceDisplay[i].text);
act->setCheckable(true);
act->setChecked(m_savedSourceDisplay[i].active);
act->setData(i);
}
menu.addSeparator();
auto* clearAct = menu.addAction("Clear All");
clearAct->setData(QStringLiteral("#clear"));
}
int lineH = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT, 0);
int x = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
0, m_editState.posStart);
int y = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
0, m_editState.posStart);
QPoint pos = m_sci->viewport()->mapToGlobal(QPoint(x, y + lineH));
QAction* sel = menu.exec(pos);
if (sel) {
const LineMeta* lm = metaForLine(m_editState.line);
uint64_t addr = lm ? lm->offsetAddr : 0;
auto info = endInlineEdit();
QString text = sel->text();
if (sel->data().toString() == QStringLiteral("#clear"))
text = QStringLiteral("#clear");
else if (sel->data().isValid())
text = QStringLiteral("#saved:") + QString::number(sel->data().toInt());
emit inlineEditCommitted(info.nodeIdx, info.subLine, info.target, text, addr);
} else {
cancelInlineEdit();
}
}
void RcxEditor::updateTypeListFilter() {
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);
int col = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETCOLUMN,
(unsigned long)curPos);
// Extract text from spanStart to cursor
int len = col - m_editState.spanStart;
if (len <= 0) {
showTypeListFiltered(QString()); // Show full list
return;
}
QString typed = lineText.mid(m_editState.spanStart, len);
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());
// Force Arrow cursor immediately (don't wait for mouse move)
m_sci->viewport()->setCursor(Qt::ArrowCursor);
}
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) {
const LineMeta* lm = metaForLine(line);
if (!lm) return;
// CommandRow: paint Source/BaseAddress + root class (type+name) spans
if (lm->lineKind == LineKind::CommandRow) {
NormalizedSpan norm;
if (resolvedSpanFor(line, EditTarget::Source, norm))
fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end);
if (resolvedSpanFor(line, EditTarget::BaseAddress, norm))
fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end);
// RootClassType no longer shown as editable — right-click conversion instead
if (resolvedSpanFor(line, EditTarget::RootClassName, norm))
fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end);
return;
}
if (isSyntheticLine(*lm)) return;
NormalizedSpan norm;
for (EditTarget t : {EditTarget::Type, EditTarget::Name, EditTarget::Value,
EditTarget::ArrayElementType, EditTarget::ArrayElementCount,
EditTarget::PointerTarget}) {
if (resolvedSpanFor(line, t, norm))
fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end);
}
}
void RcxEditor::updateEditableIndicators(int line) {
if (m_editState.active) return;
if (line == m_hintLine) return;
// No cursor hints when selection is empty (prevents desync during batch ops)
if (m_currentSelIds.isEmpty()) {
if (m_hintLine >= 0) {
clearIndicatorLine(IND_EDITABLE, m_hintLine);
m_hintLine = -1;
}
return;
}
// Helper to check if a line's node is selected (handles footer/array element IDs)
auto isLineSelected = [this](const LineMeta* lm) -> bool {
if (!lm) return false;
uint64_t checkId;
if (lm->lineKind == LineKind::Footer)
checkId = lm->nodeId | kFooterIdBit;
else if (lm->isArrayElement && lm->arrayElementIdx >= 0)
checkId = makeArrayElemSelId(lm->nodeId, lm->arrayElementIdx);
else if (lm->isMemberLine && lm->subLine >= 0)
checkId = makeMemberSelId(lm->nodeId, lm->subLine);
else
checkId = lm->nodeId;
return m_currentSelIds.contains(checkId);
};
// If new line is selected, its indicators are managed by applySelectionOverlay
// But we still need to clear the old non-selected hint line
const LineMeta* newLm = metaForLine(line);
if (isLineSelected(newLm)) {
if (m_hintLine >= 0) {
const LineMeta* oldLm = metaForLine(m_hintLine);
if (!isLineSelected(oldLm))
clearIndicatorLine(IND_EDITABLE, m_hintLine);
}
m_hintLine = line;
return;
}
// Clear old cursor line (only if not a selected node)
if (m_hintLine >= 0) {
const LineMeta* oldLm = metaForLine(m_hintLine);
if (!isLineSelected(oldLm))
clearIndicatorLine(IND_EDITABLE, m_hintLine);
}
m_hintLine = line;
paintEditableSpans(line);
}
// ── Hover cursor ──
void RcxEditor::applyHoverCursor() {
// Clear previous hover span indicators
for (int ln : m_hoverSpanLines)
clearIndicatorLine(IND_HOVER_SPAN, ln);
m_hoverSpanLines.clear();
// Lock cursor to Arrow during drag-selection (prevents flicker)
if (m_dragStarted) {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
return;
}
// Edit mode: IBeam inside edit span, Arrow outside
if (m_editState.active) {
if (m_sci->isListActive()) {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
} else {
auto h = hitTest(m_lastHoverPos);
if (h.line == m_editState.line &&
h.col >= m_editState.spanStart && h.col <= editEndCol()) {
m_sci->viewport()->setCursor(Qt::IBeamCursor);
} else {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
}
}
// Value history popup — only during inline value editing on a heated node
{
bool showPopup = false;
if (m_valueHistory && m_editState.target == EditTarget::Value
&& m_editState.line >= 0 && m_editState.line < m_meta.size()) {
const LineMeta& lm = m_meta[m_editState.line];
if (lm.heatLevel > 0 && lm.nodeId != 0) {
auto it = m_valueHistory->find(lm.nodeId);
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
if (!m_historyPopup) {
m_historyPopup = new ValueHistoryPopup(this);
static_cast<ValueHistoryPopup*>(m_historyPopup)->setOnMouseMove([this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
m_hoverInside = m_sci->viewport()->rect().contains(vp);
if (!m_editState.active) {
auto h2 = hitTest(m_lastHoverPos);
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
m_hoveredNodeId = nid;
m_hoveredLine = nln;
applyHoverHighlight();
}
}
applyHoverCursor();
});
}
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
popup->setOnSet([this](const QString& val) {
if (!m_editState.active) return;
long endPos = posFromCol(m_sci, m_editState.line, editEndCol());
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL,
m_editState.posStart, endPos);
QByteArray utf8 = val.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL,
(uintptr_t)0, utf8.constData());
});
popup->populate(lm.nodeId, *it, editorFont(), true);
int px = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
(unsigned long)0, m_editState.posStart);
int py = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
(unsigned long)0, m_editState.posStart);
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)m_editState.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor, lh);
showPopup = true;
}
}
}
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
// Always dismiss disasm/preview popups during inline editing
if (m_disasmPopup) static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup) static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
return;
}
// Mouse left viewport - set Arrow, dismiss popups
// (but not during applyDocument — the Leave is synthetic from setText)
if (!m_hoverInside) {
if (!m_applyingDocument)
dismissAllPopups();
m_sci->viewport()->setCursor(Qt::ArrowCursor);
return;
}
// If autocomplete/user list popup is active, use arrow cursor
if (m_sci->isListActive()) {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
return;
}
auto h = hitTest(m_lastHoverPos);
int line, hCol; EditTarget t;
bool tokenHit = hitTestTarget(m_sci, m_meta, m_lastHoverPos, line, hCol, t);
// Skip hover span on footer lines (nothing editable)
int hoverLine = h.line;
bool isFooterLine = (hoverLine >= 0 && hoverLine < m_meta.size()
&& m_meta[hoverLine].lineKind == LineKind::Footer);
// Apply hover span indicator for editable tokens
if (tokenHit && !isFooterLine) {
NormalizedSpan span;
QString lineText;
if (resolvedSpanFor(line, t, span, &lineText)) {
// For vector/matrix values: narrow hover to the component under cursor
bool narrowed = false;
if (t == EditTarget::Value && line >= 0 && line < m_meta.size()) {
const auto& lm = m_meta[line];
if (isVectorKind(lm.nodeKind) || isMatrixKind(lm.nodeKind)) {
QString val = lineText.mid(span.start, span.end - span.start);
int innerStart = span.start;
QString inner = val;
if (isMatrixKind(lm.nodeKind)) {
int bo = val.indexOf('['), bc = val.lastIndexOf(']');
if (bo >= 0 && bc > bo) {
inner = val.mid(bo + 1, bc - bo - 1);
innerStart = span.start + bo + 1;
}
}
QVector<int> starts, ends;
starts.append(0);
for (int i = 0; i < inner.size(); i++) {
if (inner[i] == ',') {
ends.append(i);
int n = i + 1;
while (n < inner.size() && inner[n] == ' ') n++;
starts.append(n);
}
}
ends.append(inner.size());
// Trim trailing spaces from last component to get true end
int lastEnd = ends.last();
while (lastEnd > 0 && inner[lastEnd - 1] == ' ') lastEnd--;
// Skip highlight if cursor is past the last component
int relCol = h.col - innerStart;
if (relCol >= lastEnd) {
narrowed = true; // suppress highlight entirely
} else {
int comp = 0;
for (int i = 0; i < starts.size(); i++) {
if (relCol >= starts[i] && (i == starts.size() - 1 || relCol < starts[i + 1])) {
comp = i; break;
}
}
int cS = innerStart + starts[comp];
int cE = innerStart + ends[comp];
while (cE > cS && lineText[cE - 1] == ' ') cE--;
span.start = cS;
span.end = cE;
narrowed = true;
fillIndicatorCols(IND_HOVER_SPAN, line, span.start, span.end);
m_hoverSpanLines.append(line);
}
}
// Narrow pointer-like nodes to address portion only (exclude symbol)
if (!narrowed && (isFuncPtr(lm.nodeKind)
|| lm.nodeKind == NodeKind::Pointer32
|| lm.nodeKind == NodeKind::Pointer64)) {
ColumnSpan full = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
ColumnSpan narrow = narrowPtrValueSpan(lm, full, lineText);
if (h.col >= narrow.start && h.col < narrow.end) {
fillIndicatorCols(IND_HOVER_SPAN, line, narrow.start, narrow.end);
m_hoverSpanLines.append(line);
}
narrowed = true;
}
}
if (!narrowed && h.col >= span.start && h.col < span.end) {
fillIndicatorCols(IND_HOVER_SPAN, line, span.start, span.end);
m_hoverSpanLines.append(line);
}
}
}
// Apply hover span on fold arrows (▸/▾) — same visual feedback as editable tokens
if (h.inFoldCol && h.line >= 0 && h.line < m_meta.size()) {
fillIndicatorCols(IND_HOVER_SPAN, h.line, 0, kFoldCol);
m_hoverSpanLines.append(h.line);
}
// Apply hover span on footer pills (+10h/+100h/+1000h, +10, Trim)
if (h.line >= 0 && h.line < m_meta.size()
&& m_meta[h.line].lineKind == LineKind::Footer) {
QString ft = getLineText(m_sci, h.line);
auto tryPill = [&](const QString& text, int pos) {
if (pos >= 0 && h.col >= pos && h.col < pos + text.size()) {
fillIndicatorCols(IND_HOVER_SPAN, h.line, pos, pos + text.size());
m_hoverSpanLines.append(h.line);
}
};
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
tryPill(QStringLiteral("+1000h"), p1000);
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1)
tryPill(QStringLiteral("+100h"), p100);
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000)
tryPill(QStringLiteral("+10h"), p10);
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000)
tryPill(QStringLiteral("+10"), add10Start);
tryPill(QStringLiteral("Trim"), ft.indexOf(QStringLiteral("Trim")));
}
// Value history popup on hover (read-only, no buttons)
// Skip FuncPtr and void-Pointer nodes — they use the disasm popup instead.
{
bool showPopup = false;
if (m_valueHistory && h.line >= 0 && h.line < m_meta.size()) {
const LineMeta& lm = m_meta[h.line];
bool skipForDisasm = isFuncPtr(lm.nodeKind)
|| ((lm.nodeKind == NodeKind::Pointer32
|| lm.nodeKind == NodeKind::Pointer64)
&& lm.pointerTargetName.isEmpty());
if (lm.heatLevel > 0 && lm.nodeId != 0 && !skipForDisasm) {
auto it = m_valueHistory->find(lm.nodeId);
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
QString lineText = getLineText(m_sci, h.line);
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
if (!m_historyPopup) {
m_historyPopup = new ValueHistoryPopup(this);
static_cast<ValueHistoryPopup*>(m_historyPopup)->setOnMouseMove([this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
m_hoverInside = m_sci->viewport()->rect().contains(vp);
if (!m_editState.active) {
auto h2 = hitTest(m_lastHoverPos);
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
m_hoveredNodeId = nid;
m_hoveredLine = nln;
applyHoverHighlight();
}
}
applyHoverCursor();
});
}
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
popup->populate(lm.nodeId, *it, editorFont(), false);
long linePos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)h.line);
long byteOff = lineText.left(vs.start).toUtf8().size();
int px = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
(unsigned long)0, linePos + byteOff);
int py = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
(unsigned long)0, linePos);
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor, lh);
showPopup = true;
}
}
}
}
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
// Disasm / hex-dump popup on hover for FuncPtr and void Pointer nodes
{
bool showDisasm = false;
if (m_disasmProvider && m_disasmTree && h.line >= 0 && h.line < m_meta.size()) {
const LineMeta& lm = m_meta[h.line];
bool isFP = isFuncPtr(lm.nodeKind);
bool isVoidPtr = (lm.nodeKind == NodeKind::Pointer32
|| lm.nodeKind == NodeKind::Pointer64)
&& lm.pointerTargetName.isEmpty();
if ((isFP || isVoidPtr) && lm.nodeIdx >= 0
&& lm.nodeIdx < m_disasmTree->nodes.size()) {
// Check hover is over the address portion of the value column
QString lineText = getLineText(m_sci, h.line);
ColumnSpan vs = narrowPtrValueSpan(lm,
valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW),
lineText);
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
const Node& node = m_disasmTree->nodes[lm.nodeIdx];
// For void ptrs, only show hex dump if refId == 0
if (!isVoidPtr || node.refId == 0) {
bool is64 = (lm.nodeKind == NodeKind::FuncPtr64
|| lm.nodeKind == NodeKind::Pointer64);
// Use composed address (absolute, correct for pointer-expanded nodes)
uint64_t provAddr = lm.offsetAddr;
uint64_t ptrVal = is64
? m_disasmProvider->readU64(provAddr)
: (uint64_t)m_disasmProvider->readU32(provAddr);
if (ptrVal != 0 && ptrVal != UINT64_MAX
&& !(is64 == false && ptrVal == 0xFFFFFFFF)) {
// Read code bytes from the function target address.
// Use the real provider (not snapshot) because function
// code lives at arbitrary process addresses that aren't
// in the snapshot page table.
const Provider* codeProv = m_disasmRealProv
? m_disasmRealProv : m_disasmProvider;
constexpr int kMaxRead = 128;
uint64_t codeAddr = ptrVal;
QByteArray bytes(kMaxRead, Qt::Uninitialized);
bool readOk = codeProv->read(codeAddr, bytes.data(), kMaxRead);
if (readOk) {
QString title, body;
if (isFP) {
title = QStringLiteral("Disassembly");
body = disassemble(bytes, ptrVal,
is64 ? 64 : 32, kMaxRead);
} else {
title = QStringLiteral("Hex Dump");
body = hexDump(bytes, ptrVal, kMaxRead);
}
// Cap at 6 lines so the popup stays compact
{
const int kMaxLines = 6;
int nth = 0, idx = 0;
while (nth < kMaxLines && (idx = body.indexOf('\n', idx)) != -1)
{ ++nth; ++idx; }
if (nth == kMaxLines && idx < body.size()) {
body.truncate(idx);
body += QStringLiteral("...");
}
}
if (!body.isEmpty()) {
if (!m_disasmPopup) {
m_disasmPopup = new TitleBodyPopup(this);
static_cast<TitleBodyPopup*>(m_disasmPopup)->setOnMouseMove([this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
m_hoverInside = m_sci->viewport()->rect().contains(vp);
if (!m_editState.active) {
auto h2 = hitTest(m_lastHoverPos);
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
m_hoveredNodeId = nid;
m_hoveredLine = nln;
applyHoverHighlight();
}
}
applyHoverCursor();
});
}
auto* popup = static_cast<TitleBodyPopup*>(
m_disasmPopup);
popup->populate(lm.nodeId, title, body,
editorFont(),
ThemeManager::instance().current().syntaxNumber);
long linePos = m_sci->SendScintilla(
QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)h.line);
long byteOff = lineText.left(vs.start)
.toUtf8().size();
int px = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTXFROMPOSITION,
(unsigned long)0, linePos + byteOff);
int py = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTYFROMPOSITION,
(unsigned long)0, linePos);
int lh = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint(px, py + lh));
popup->showAt(anchor, lh);
showDisasm = true;
// Dismiss value history popup to avoid fighting
if (m_historyPopup && m_historyPopup->isVisible())
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
}
}
}
}
}
}
if (!showDisasm && m_disasmPopup && m_disasmPopup->isVisible())
static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
}
// Struct preview popup for collapsed typed pointers
{
bool showPreview = false;
if (m_disasmTree && m_disasmProvider && h.line >= 0 && h.line < m_meta.size()) {
const LineMeta& lm = m_meta[h.line];
bool isTypedPtr = (lm.nodeKind == NodeKind::Pointer32
|| lm.nodeKind == NodeKind::Pointer64)
&& !lm.pointerTargetName.isEmpty();
if (isTypedPtr && lm.foldCollapsed
&& lm.nodeIdx >= 0 && lm.nodeIdx < m_disasmTree->nodes.size()) {
const Node& node = m_disasmTree->nodes[lm.nodeIdx];
if (node.refId != 0) {
QString lineText = getLineText(m_sci, h.line);
ColumnSpan vs = narrowPtrValueSpan(lm,
valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW),
lineText);
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
ComposeResult cr = rcx::compose(*m_disasmTree, *m_disasmProvider, node.refId);
// Skip command row (line 0), take first 5 data lines
QStringList lines = cr.text.split('\n');
constexpr int kMaxLines = 5;
QString body;
int count = 0;
for (int i = 1; i < lines.size() && count < kMaxLines; ++i) {
if (!lines[i].isEmpty()) {
if (count > 0) body += '\n';
body += lines[i];
++count;
}
}
if (!body.isEmpty()) {
if (!m_structPreviewPopup) {
m_structPreviewPopup = new TitleBodyPopup(this);
static_cast<TitleBodyPopup*>(m_structPreviewPopup)->setOnMouseMove([this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
m_hoverInside = m_sci->viewport()->rect().contains(vp);
if (!m_editState.active) {
auto h2 = hitTest(m_lastHoverPos);
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
m_hoveredNodeId = nid;
m_hoveredLine = nln;
applyHoverHighlight();
}
}
applyHoverCursor();
});
}
auto* popup = static_cast<TitleBodyPopup*>(m_structPreviewPopup);
popup->populate(lm.nodeId,
lm.pointerTargetName, body, editorFont(),
ThemeManager::instance().current().text);
long linePos = m_sci->SendScintilla(
QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)h.line);
long byteOff = lineText.left(vs.start).toUtf8().size();
int px = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTXFROMPOSITION,
(unsigned long)0, linePos + byteOff);
int py = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTYFROMPOSITION,
(unsigned long)0, linePos);
int lh = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint(px, py + lh));
popup->showAt(anchor, lh);
showPreview = true;
if (m_historyPopup && m_historyPopup->isVisible())
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
}
}
}
}
if (!showPreview && m_structPreviewPopup && m_structPreviewPopup->isVisible())
static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
}
// Determine cursor shape based on interaction type
Qt::CursorShape desired = Qt::ArrowCursor;
if (h.inFoldCol) {
desired = Qt::PointingHandCursor; // fold toggle = button
} else if (h.line >= 0 && h.line < m_meta.size()
&& m_meta[h.line].lineKind == LineKind::Footer) {
QString ft = getLineText(m_sci, h.line);
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
if (p1000 >= 0 && h.col >= p1000 && h.col < p1000 + 6)
desired = Qt::PointingHandCursor;
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1 && h.col >= p100 && h.col < p100 + 5)
desired = Qt::PointingHandCursor;
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000 && h.col >= p10 && h.col < p10 + 4)
desired = Qt::PointingHandCursor;
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000
&& h.col >= add10Start && h.col < add10Start + 3)
desired = Qt::PointingHandCursor;
int trimStart = ft.indexOf(QStringLiteral("Trim"));
if (trimStart >= 0 && h.col >= trimStart && h.col < trimStart + 4)
desired = Qt::PointingHandCursor;
} else if (tokenHit) {
// Check if mouse is actually over trimmed text content (not column padding)
NormalizedSpan trimmed;
bool overText = resolvedSpanFor(line, t, trimmed)
&& h.col >= trimmed.start && h.col < trimmed.end;
if (overText) {
switch (t) {
case EditTarget::Type:
case EditTarget::Source:
case EditTarget::ArrayElementType:
case EditTarget::PointerTarget:
case EditTarget::RootClassType:
case EditTarget::TypeSelector:
desired = Qt::PointingHandCursor;
break;
default:
desired = Qt::IBeamCursor;
break;
}
}
// else: desired stays Arrow (hovering over column padding)
}
m_sci->viewport()->setCursor(desired);
}
// ── Live value validation ──
void RcxEditor::setEditComment(const QString& comment) {
// Value edit must be active
if (m_editState.commentCol < 0) return;
// Prevent re-entrancy from textChanged signal
if (m_updatingComment) return;
m_updatingComment = true;
QString lineText = getLineText(m_sci, m_editState.line);
// Place comment 2 spaces after current value, prefixed with //
int valueEnd = editEndCol();
int startCol = qMax(valueEnd + 2, m_editState.commentCol);
int endCol = lineText.size();
int availWidth = endCol - startCol;
if (availWidth <= 0) { m_updatingComment = false; return; }
// Format as "//<comment>" (no space after //)
QString formatted = QStringLiteral("//") + comment;
QString padded = formatted.leftJustified(availWidth, ' ').left(availWidth);
// Use UTF-8 safe column-to-position conversion
long posA = posFromCol(m_sci, m_editState.line, startCol);
long posB = posFromCol(m_sci, m_editState.line, endCol);
QByteArray utf8 = padded.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, posA);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, posB);
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET,
(uintptr_t)utf8.size(), utf8.constData());
// Apply green color to hint text
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, IND_HINT_GREEN);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, posA, posB - posA);
m_updatingComment = false;
}
void RcxEditor::validateEditLive() {
QString lineText = getLineText(m_sci, m_editState.line);
int delta = lineText.size() - m_editState.linelenAfterReplace;
int editedLen = m_editState.original.size() + delta;
QString text = (editedLen > 0)
? lineText.mid(m_editState.spanStart, editedLen).trimmed() : QString();
QString errorMsg = (m_editState.target == EditTarget::BaseAddress)
? fmt::validateBaseAddress(text)
: fmt::validateValue(m_editState.editKind, text);
const LineMeta* lm = metaForLine(m_editState.line);
const bool isSelected = lm && m_currentSelIds.contains(lm->nodeId);
const bool isValid = errorMsg.isEmpty();
// Only update comment when validation state changes (avoid lag)
const bool stateChanged = (isValid != m_editState.lastValidationOk);
m_editState.lastValidationOk = isValid;
// Show/hide error marker (red background)
// M_SELECTED has higher priority than M_ERR, so temporarily remove it when error
if (isValid) {
m_sci->markerDelete(m_editState.line, M_ERR);
if (isSelected) m_sci->markerAdd(m_editState.line, M_SELECTED);
if (stateChanged) {
if (m_editState.target == EditTarget::BaseAddress)
setEditComment(QStringLiteral("e.g. <mod.exe> + 0xFF | [0x1000 + 0x10] | 7ff6`1234ABCD"));
else
setEditComment("Enter=Save Esc=Cancel");
}
} else {
if (isSelected) m_sci->markerDelete(m_editState.line, M_SELECTED);
m_sci->markerAdd(m_editState.line, M_ERR);
if (stateChanged) setEditComment("! " + errorMsg);
}
}
void RcxEditor::setCommandRowText(const QString& line) {
if (m_sci->lines() <= 0) return;
QString s = line;
s.replace('\n', ' ');
s.replace('\r', ' ');
bool wasReadOnly = m_sci->isReadOnly();
bool wasModified = m_sci->SendScintilla(QsciScintillaBase::SCI_GETMODIFY);
long savedPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS);
long savedAnchor = m_sci->SendScintilla(QsciScintillaBase::SCI_GETANCHOR);
m_sci->setReadOnly(false);
long start = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 0);
long end = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, 0);
QByteArray utf8 = s.toUtf8();
long oldLen = end - start;
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, start);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, end);
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET, (uintptr_t)utf8.size(), utf8.constData());
// Adjust saved cursor/anchor for length change in line 0
long delta = (long)utf8.size() - oldLen;
if (savedPos > end) savedPos += delta;
if (savedAnchor > end) savedAnchor += delta;
if (wasReadOnly) m_sci->setReadOnly(true);
if (!wasModified) m_sci->SendScintilla(QsciScintillaBase::SCI_SETSAVEPOINT);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCURRENTPOS, savedPos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETANCHOR, savedAnchor);
m_sci->SendScintilla(QsciScintillaBase::SCI_COLOURISE, start, start + utf8.size());
applyCommandRowPills();
}
void RcxEditor::setEditorFont(const QString& fontName) {
g_fontName = fontName;
QFont f = editorFont();
m_sci->setFont(f);
m_lexer->setFont(f);
for (int i = 0; i <= 127; i++)
m_lexer->setFont(f, i);
m_sci->setMarginsFont(f);
// Re-apply margin styles and width with new font metrics
allocateMarginStyles();
applyTheme(ThemeManager::instance().current());
{
int marginDigits = m_relativeOffsets
? qMax(m_layout.offsetHexDigits / 2, 4)
: m_layout.offsetHexDigits;
QString marginSizer = QString(" %1 ").arg(QString(marginDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
}
}
void RcxEditor::setGlobalFontName(const QString& fontName) {
g_fontName = fontName;
}
QString RcxEditor::globalFontName() {
return g_fontName;
}
QString RcxEditor::textWithMargins() const {
int lineCount = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINECOUNT);
QStringList lines;
lines.reserve(lineCount);
for (int i = 0; i < lineCount; i++) {
QString margin;
if (i < m_meta.size())
margin = m_meta[i].offsetText;
QString lineText = getLineText(m_sci, i);
lines.append(margin + lineText);
}
return lines.join('\n');
}
} // namespace rcx