feat: see-through popup dismiss for disasm/value-history/struct-preview

Override mouseMoveEvent in all three popup classes to forward mouse
position back to viewport hover logic. When the row underneath the
popup represents a different node, the popup dismisses automatically,
allowing rapid swiping through FuncPtr rows.
This commit is contained in:
IChooseYou
2026-03-05 18:25:40 -07:00
parent 877ceea4c1
commit f981fe456d
3 changed files with 227 additions and 6 deletions

View File

@@ -40,18 +40,30 @@ class ValueHistoryPopup : public QFrame {
QStringList m_values;
QVector<QLabel*> m_labels;
std::function<void(const QString&)> m_onSet;
std::function<void(QMouseEvent*)> m_onMouseMove;
public:
explicit ValueHistoryPopup(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; }
bool hasButtons() const { return m_hasButtons; }
void setOnSet(std::function<void(const QString&)> fn) { m_onSet = std::move(fn); }
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = 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) {
@@ -185,12 +197,14 @@ class DisasmPopup : public QFrame {
QString m_body;
QLabel* m_titleLabel = nullptr;
QLabel* m_bodyLabel = nullptr;
std::function<void(QMouseEvent*)> m_onMouseMove;
public:
explicit DisasmPopup(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);
@@ -216,8 +230,14 @@ public:
vbox->addWidget(m_bodyLabel);
}
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
uint64_t nodeId() const { return m_nodeId; }
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (m_onMouseMove) m_onMouseMove(e);
else QFrame::mouseMoveEvent(e);
}
public:
void populate(uint64_t nodeId, const QString& title, const QString& body,
const QFont& font) {
if (nodeId == m_nodeId && body == m_body && isVisible())
@@ -283,12 +303,14 @@ class StructPreviewPopup : public QFrame {
QString m_body;
QLabel* m_titleLabel = nullptr;
QLabel* m_bodyLabel = nullptr;
std::function<void(QMouseEvent*)> m_onMouseMove;
public:
explicit StructPreviewPopup(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);
@@ -315,7 +337,13 @@ public:
}
uint64_t nodeId() const { return m_nodeId; }
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (m_onMouseMove) m_onMouseMove(e);
else QFrame::mouseMoveEvent(e);
}
public:
void populate(uint64_t nodeId, const QString& title, const QString& body,
const QFont& font) {
if (nodeId == m_nodeId && body == m_body && isVisible())
@@ -3002,8 +3030,26 @@ void RcxEditor::applyHoverCursor() {
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)
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;
@@ -3163,8 +3209,26 @@ void RcxEditor::applyHoverCursor() {
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)
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,
@@ -3248,8 +3312,26 @@ void RcxEditor::applyHoverCursor() {
}
}
if (!body.isEmpty()) {
if (!m_disasmPopup)
if (!m_disasmPopup) {
m_disasmPopup = new DisasmPopup(this);
static_cast<DisasmPopup*>(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<DisasmPopup*>(
m_disasmPopup);
popup->populate(lm.nodeId, title, body,
@@ -3317,8 +3399,26 @@ void RcxEditor::applyHoverCursor() {
}
}
if (!body.isEmpty()) {
if (!m_structPreviewPopup)
if (!m_structPreviewPopup) {
m_structPreviewPopup = new StructPreviewPopup(this);
static_cast<StructPreviewPopup*>(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<StructPreviewPopup*>(m_structPreviewPopup);
popup->populate(lm.nodeId,
lm.pointerTargetName, body, editorFont());

View File

@@ -29,6 +29,8 @@ public:
void restoreViewState(const ViewState& vs);
QsciScintilla* scintilla() const { return m_sci; }
QWidget* historyPopup() const { return m_historyPopup; }
QWidget* disasmPopup() const { return m_disasmPopup; }
QWidget* structPreviewPopup() const { return m_structPreviewPopup; }
const LineMeta* metaForLine(int line) const;
int currentNodeIndex() const;

View File

@@ -2768,6 +2768,125 @@ private slots:
"Static fields should not have a separator line");
}
}
// ── Test: disasm popup dismisses when mouse moves onto it ("see-through") ──
//
// Scenario: hover a FuncPtr row → disasm popup appears below the row.
// User moves mouse down onto the popup. The popup covers rows behind it
// but the mouse position maps to a different node's row in the viewport
// underneath, so the popup must dismiss.
void testDisasmPopupDismissesOnMouseMoveThrough() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "TestClass";
root.name = "TestClass";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// FuncPtr64 at offset 0 — its value points to "code" at byte 256
Node fp;
fp.kind = NodeKind::FuncPtr64;
fp.name = "VFunc1";
fp.parentId = rootId;
fp.offset = 0;
tree.addNode(fp);
// A plain UInt64 after it so there's a non-FuncPtr row below
Node pad;
pad.kind = NodeKind::UInt64;
pad.name = "padding";
pad.parentId = rootId;
pad.offset = 8;
tree.addNode(pad);
// Buffer layout:
// [0..7] FuncPtr value = 256 (points to code bytes)
// [8..15] padding field value
// [256..383] x86 code bytes (push rbp; mov rbp,rsp; nop...; ret)
QByteArray data(512, '\0');
uint64_t codeAddr = 256;
memcpy(data.data(), &codeAddr, 8);
const uint8_t code[] = {
0x55, // push rbp
0x48, 0x89, 0xE5, // mov rbp, rsp
0x90, // nop
0x90, // nop
0x5D, // pop rbp
0xC3 // ret
};
memcpy(data.data() + 256, code, sizeof(code));
BufferProvider prov(data, "test_disasm_dismiss");
ComposeResult cr = compose(tree, prov);
m_editor->applyDocument(cr);
m_editor->setProviderRef(&prov, nullptr, &tree);
QApplication::processEvents();
// Find the FuncPtr line
int fpLine = -1;
for (int i = 0; i < cr.meta.size(); ++i) {
if (isFuncPtr(cr.meta[i].nodeKind)) {
fpLine = i;
break;
}
}
QVERIFY2(fpLine >= 0, "Could not find FuncPtr64 line in compose output");
// Hover over the FuncPtr value column to trigger the disasm popup
const LineMeta& lm = cr.meta[fpLine];
QString lineText;
{
long len = m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)fpLine);
QByteArray buf(len + 1, '\0');
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GETLINE, (uintptr_t)fpLine,
static_cast<const char*>(buf.data()));
lineText = QString::fromUtf8(buf.left(len));
}
ColumnSpan vs = m_editor->valueSpan(lm, lineText.size(),
lm.effectiveTypeW, lm.effectiveNameW);
QVERIFY2(vs.valid, "Value span for FuncPtr line is not valid");
int hoverCol = (vs.start + vs.end) / 2;
QPoint vpFP = colToViewport(m_editor->scintilla(), fpLine, hoverCol);
sendMouseMove(m_editor->scintilla()->viewport(), vpFP);
QApplication::processEvents();
QWidget* popup = m_editor->disasmPopup();
QVERIFY2(popup && popup->isVisible(),
"Disasm popup should be visible after hovering the FuncPtr value");
// See-through behavior: when the user moves the mouse down from the
// viewport onto the popup, the popup's mouseMoveEvent override forwards
// the global position back to the viewport hover logic. If the row
// underneath the popup represents a different node, the popup dismisses.
//
// Simulate by sending a MouseMove event to the popup at a global
// position that maps to the CommandRow (line 0) — a non-FuncPtr row.
// sendEvent triggers the virtual mouseMoveEvent directly.
QPoint vpCmdRow = colToViewport(m_editor->scintilla(), 0, hoverCol);
QPoint globalCmdRow = m_editor->scintilla()->viewport()->mapToGlobal(vpCmdRow);
QPoint localOnPopup = popup->mapFromGlobal(globalCmdRow);
QMouseEvent moveOnPopup(QEvent::MouseMove,
QPointF(localOnPopup), QPointF(globalCmdRow),
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(popup, &moveOnPopup);
QApplication::processEvents();
QVERIFY2(!popup->isVisible(),
"Disasm popup must dismiss when mouseMoveEvent forwards "
"to a non-FuncPtr row underneath (see-through behavior)");
// Restore
m_editor->setProviderRef(nullptr, nullptr, nullptr);
m_editor->applyDocument(m_result);
}
};
QTEST_MAIN(TestEditor)