From 6a4cb47ed45225684e63d1c6d9e96b1737fb986b Mon Sep 17 00:00:00 2001 From: IChooseYou Date: Sun, 8 Mar 2026 10:26:12 -0600 Subject: [PATCH] fix: kill Fusion outline on QScintilla, type inference hints, workspace styling - Suppress PE_Frame on QsciScintilla in MenuBarStyle to eliminate the 1px dark (#171717) Fusion outline around the editor area - Add --screenshot flag for automated pixel regression testing - Add type inference engine (typeinfer.h) with hex pattern analysis - Show inferred type hints on hex nodes in compose output - Style workspace tree corner/header widgets to match theme - Fix integer overflow in compose.cpp array element addressing - Fix integer overflow in core.h structSpan calculation - Add bounds check on activePaneIdx in controller - Use QPointer for deferred dock lambda safety - Workspace delegate uses icon Normal/Disabled for viewed state --- CMakeLists.txt | 5 + src/compose.cpp | 25 +- src/controller.cpp | 13 +- src/controller.h | 6 +- src/core.h | 8 +- src/editor.cpp | 439 +++++++++++++--------------------- src/editor.h | 5 +- src/main.cpp | 439 +++++++++++++++++++++++++++------- src/typeinfer.h | 499 +++++++++++++++++++++++++++++++++++++++ src/workspace_model.h | 4 +- tests/test_pixels.py | 92 ++++++++ tests/test_typeinfer.cpp | 189 +++++++++++++++ 12 files changed, 1346 insertions(+), 378 deletions(-) create mode 100644 src/typeinfer.h create mode 100644 tests/test_pixels.py create mode 100644 tests/test_typeinfer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f1ca9f3..3f27c0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -314,6 +314,11 @@ if(BUILD_TESTING) target_link_libraries(test_core PRIVATE ${QT}::Core ${QT}::Test) add_test(NAME test_core COMMAND test_core) + add_executable(test_typeinfer tests/test_typeinfer.cpp) + target_include_directories(test_typeinfer PRIVATE src) + target_link_libraries(test_typeinfer PRIVATE ${QT}::Core ${QT}::Test) + add_test(NAME test_typeinfer COMMAND test_typeinfer) + add_executable(test_format tests/test_format.cpp src/format.cpp src/addressparser.cpp) target_include_directories(test_format PRIVATE src) target_link_libraries(test_format PRIVATE ${QT}::Core ${QT}::Test) diff --git a/src/compose.cpp b/src/compose.cpp index 98ccaf1..9cc9029 100644 --- a/src/compose.cpp +++ b/src/compose.cpp @@ -1,4 +1,5 @@ #include "core.h" +#include "typeinfer.h" #include "addressparser.h" #include #include @@ -26,6 +27,7 @@ struct ComposeState { bool compactColumns = false; // compact column mode: cap type width, overflow long types bool treeLines = false; // draw Unicode tree connectors in indentation bool braceWrap = false; // opening brace on its own line + bool typeHints = false; // show type inference hints on hex nodes QVector siblingStack; // per-depth: true = more siblings follow at this level uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target @@ -208,6 +210,21 @@ void composeLeaf(ComposeState& state, const NodeTree& tree, QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub, /*comment=*/{}, typeW, nameW, ptrTypeOverride, state.compactColumns); + + // Type inference hint for hex nodes (when enabled) + if (state.typeHints && isHexNode(node.kind) && sub == 0) { + const int sz = sizeForKind(node.kind); + QByteArray b = prov.isReadable(absAddr, sz) + ? prov.readBytes(absAddr, sz) : QByteArray(sz, '\0'); + auto suggestions = inferTypes( + reinterpret_cast(b.constData()), sz); + if (!suggestions.isEmpty()) { + lm.typeHintStart = lineText.size() + 2; // after " " gap + lm.typeHint = formatHint(suggestions[0]); + lineText += QStringLiteral(" ") + lm.typeHint; + } + } + state.emitLine(lineText, std::move(lm)); } } @@ -469,7 +486,7 @@ void composeParent(ComposeState& state, const NodeTree& tree, int eNW = state.effectiveNameW(node.id); for (int i = 0; i < node.arrayLen; i++) { state.setTreeSibling(childDepth, i < node.arrayLen - 1); - uint64_t elemAddr = absAddr + i * elemSize; + uint64_t elemAddr = absAddr + (uint64_t)i * elemSize; // Type override: "float[0]", "uint32_t[1]", etc. QString elemTypeStr = fmt::typeNameRaw(node.elementKind) @@ -478,7 +495,7 @@ void composeParent(ComposeState& state, const NodeTree& tree, Node elem; elem.kind = node.elementKind; elem.name = QString(); // no name for array elements - elem.offset = node.offset + i * elemSize; + elem.offset = node.offset + (int)((uint64_t)i * elemSize); elem.parentId = node.id; elem.id = 0; @@ -971,11 +988,13 @@ void composeNode(ComposeState& state, const NodeTree& tree, } // anonymous namespace ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId, - bool compactColumns, bool treeLines, bool braceWrap) { + bool compactColumns, bool treeLines, bool braceWrap, + bool typeHints) { ComposeState state; state.compactColumns = compactColumns; state.treeLines = treeLines; state.braceWrap = braceWrap; + state.typeHints = typeHints; // Precompute parent→children map for (int i = 0; i < tree.nodes.size(); i++) diff --git a/src/controller.cpp b/src/controller.cpp index 6146eda..83d3ed7 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -73,8 +73,8 @@ RcxDocument::RcxDocument(QObject* parent) } ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns, - bool treeLines, bool braceWrap) const { - return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines, braceWrap); + bool treeLines, bool braceWrap, bool typeHints) const { + return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines, braceWrap, typeHints); } bool RcxDocument::save(const QString& path) { @@ -548,9 +548,9 @@ void RcxController::refresh() { // Compose against snapshot provider if active, otherwise real provider if (m_snapshotProv) - m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap); + m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints); else - m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap); + m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints); s_composeDoc = nullptr; @@ -3313,6 +3313,11 @@ void RcxController::setBraceWrap(bool v) { refresh(); } +void RcxController::setTypeHints(bool v) { + m_typeHints = v; + refresh(); +} + void RcxController::setupAutoRefresh() { int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt(); m_refreshTimer = new QTimer(this); diff --git a/src/controller.h b/src/controller.h index 0641dc4..b8576bc 100644 --- a/src/controller.h +++ b/src/controller.h @@ -41,7 +41,8 @@ public: } ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false, - bool treeLines = false, bool braceWrap = false) const; + bool treeLines = false, bool braceWrap = false, + bool typeHints = false) const; bool save(const QString& path); bool load(const QString& path); void loadData(const QString& binaryPath); @@ -131,6 +132,8 @@ public: void setCompactColumns(bool v); void setTreeLines(bool v); void setBraceWrap(bool v); + void setTypeHints(bool v); + bool typeHints() const { return m_typeHints; } void resetProvider(); // MCP bridge accessors @@ -171,6 +174,7 @@ private: bool m_compactColumns = false; bool m_treeLines = false; bool m_braceWrap = false; + bool m_typeHints = false; uint64_t m_viewRootId = 0; // ── Saved sources for quick-switch ── diff --git a/src/core.h b/src/core.h index 08adcdb..51025c5 100644 --- a/src/core.h +++ b/src/core.h @@ -450,8 +450,8 @@ struct NodeTree { if (c.isStatic) continue; // static fields don't affect struct size int sz = (c.kind == NodeKind::Struct || c.kind == NodeKind::Array) ? structSpan(c.id, childMap, visited) : c.byteSize(); - int end = c.offset + sz; - if (end > maxEnd) maxEnd = end; + int64_t end = (int64_t)c.offset + sz; + if (end > maxEnd) maxEnd = (int)qMin(end, (int64_t)INT_MAX); } // Embedded struct reference: no own children but refId points to a struct definition @@ -625,6 +625,8 @@ struct LineMeta { bool isArrayElement = false; // true for synthesized primitive array element lines bool isMemberLine = false; // true for enum member / bitfield member lines bool isStaticLine = false; // true for static field node lines + QString typeHint; // Type inference hint text (e.g. "Float×2") — only set for hex nodes when hints enabled + int typeHintStart = -1; // Character offset where hint text starts in line text (-1 = none) }; inline bool isSyntheticLine(const LineMeta& lm) { @@ -1037,6 +1039,6 @@ namespace fmt { ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0, bool compactColumns = false, bool treeLines = false, - bool braceWrap = false); + bool braceWrap = false, bool typeHints = false); } // namespace rcx diff --git a/src/editor.cpp b/src/editor.cpp index c45fcb4..f86632e 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -32,17 +32,14 @@ namespace rcx { // Forward declaration (defined below, after RcxEditor constructor) static QString getLineText(QsciScintilla* sci, int line); -// ── Value history popup (styled like TypeSelectorPopup) ── +// ── Base class for all hover popups ── -class ValueHistoryPopup : public QFrame { +class HoverPopup : public QFrame { +protected: uint64_t m_nodeId = 0; - bool m_hasButtons = false; - QStringList m_values; - QVector m_labels; - std::function m_onSet; std::function m_onMouseMove; public: - explicit ValueHistoryPopup(QWidget* parent) + explicit HoverPopup(QWidget* parent) : QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint) { setAttribute(Qt::WA_DeleteOnClose, false); @@ -53,9 +50,129 @@ public: } uint64_t nodeId() const { return m_nodeId; } + void setOnMouseMove(std::function 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()) { + 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 m_labels; + std::function m_onSet; +public: + explicit ValueHistoryPopup(QWidget* parent) : HoverPopup(parent) {} + bool hasButtons() const { return m_hasButtons; } void setOnSet(std::function fn) { m_onSet = std::move(fn); } - void setOnMouseMove(std::function fn) { m_onMouseMove = std::move(fn); } + protected: void mouseMoveEvent(QMouseEvent* e) override { if (!m_hasButtons && m_onMouseMove) @@ -63,8 +180,8 @@ protected: else QFrame::mouseMoveEvent(e); } -public: +public: void populate(uint64_t nodeId, const ValueHistory& hist, const QFont& font, bool showButtons = false) { QStringList vals; @@ -93,10 +210,7 @@ public: qDeleteAll(findChildren(QString(), Qt::FindDirectChildrenOnly)); const auto& theme = ThemeManager::instance().current(); - QPalette pal; - pal.setColor(QPalette::Window, theme.backgroundAlt); - pal.setColor(QPalette::WindowText, theme.text); - setPalette(pal); + applyThemePalette(theme); auto* vbox = new QVBoxLayout(this); vbox->setContentsMargins(8, 6, 8, 6); @@ -169,240 +283,13 @@ public: adjustSize(); } - 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(); - } - - void dismiss() { - if (isVisible()) hide(); - m_nodeId = 0; + void dismiss() override { + HoverPopup::dismiss(); m_values.clear(); m_labels.clear(); } }; -// ── Disassembly / hex-dump hover popup ── - -class DisasmPopup : public QFrame { - uint64_t m_nodeId = 0; - QString m_body; - QLabel* m_titleLabel = nullptr; - QLabel* m_bodyLabel = nullptr; - std::function 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); - - 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 setOnMouseMove(std::function 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()) - return; - - m_nodeId = nodeId; - m_body = body; - - const auto& theme = ThemeManager::instance().current(); - QPalette pal; - pal.setColor(QPalette::Window, theme.backgroundAlt); - pal.setColor(QPalette::WindowText, theme.text); - setPalette(pal); - - QFont bold = font; - bold.setBold(true); - m_titleLabel->setFont(bold); - m_titleLabel->setText(title); - m_titleLabel->setStyleSheet( - QStringLiteral("color: %1;").arg(theme.text.name())); - - // Find and style the separator - for (auto* child : findChildren()) { - if (child->frameShape() == QFrame::HLine) { - QPalette sp; - sp.setColor(QPalette::WindowText, theme.border); - child->setPalette(sp); - break; - } - } - - m_bodyLabel->setFont(font); - m_bodyLabel->setText(body); - m_bodyLabel->setStyleSheet( - QStringLiteral("color: %1;").arg(theme.syntaxNumber.name())); - - setMaximumWidth(600); - adjustSize(); - } - - 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(); - } - - void dismiss() { - if (isVisible()) hide(); - m_nodeId = 0; - m_body.clear(); - } -}; - -class StructPreviewPopup : public QFrame { - uint64_t m_nodeId = 0; - QString m_body; - QLabel* m_titleLabel = nullptr; - QLabel* m_bodyLabel = nullptr; - std::function 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); - - 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); - } - - uint64_t nodeId() const { return m_nodeId; } - void setOnMouseMove(std::function 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()) - return; - - m_nodeId = nodeId; - m_body = body; - - const auto& theme = ThemeManager::instance().current(); - QPalette pal; - pal.setColor(QPalette::Window, theme.backgroundAlt); - pal.setColor(QPalette::WindowText, theme.text); - setPalette(pal); - - QFont bold = font; - bold.setBold(true); - m_titleLabel->setFont(bold); - m_titleLabel->setText(title); - m_titleLabel->setStyleSheet( - QStringLiteral("color: %1;").arg(theme.text.name())); - - for (auto* child : findChildren()) { - if (child->frameShape() == QFrame::HLine) { - QPalette sp; - sp.setColor(QPalette::WindowText, theme.border); - child->setPalette(sp); - break; - } - } - - m_bodyLabel->setFont(font); - m_bodyLabel->setText(body); - m_bodyLabel->setStyleSheet( - QStringLiteral("color: %1;").arg(theme.text.name())); - - setMaximumWidth(600); - adjustSize(); - } - - 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(); - } - - void dismiss() { - if (isVisible()) hide(); - m_nodeId = 0; - m_body.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 @@ -415,6 +302,7 @@ static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset 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"; @@ -724,6 +612,10 @@ void RcxEditor::setupScintilla() { 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*/); @@ -869,6 +761,8 @@ void RcxEditor::applyTheme(const Theme& theme) { 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.textFaint); m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, IND_FIND, theme.borderFocused); @@ -1030,9 +924,7 @@ void RcxEditor::applyDocument(const ComposeResult& result) { if (m_hoveredNodeId != 0 && !m_nodeLineIndex.contains(m_hoveredNodeId)) { m_hoveredNodeId = 0; m_hoveredLine = -1; - dismissHistoryPopup(); - if (m_disasmPopup) m_disasmPopup->hide(); - if (m_structPreviewPopup) m_structPreviewPopup->hide(); + dismissAllPopups(); } // Re-apply hover markers (setText() clears all Scintilla markers). @@ -1463,7 +1355,13 @@ void RcxEditor::showFindBar() { void RcxEditor::dismissHistoryPopup() { if (m_historyPopup) - static_cast(m_historyPopup)->dismiss(); + static_cast(m_historyPopup)->dismiss(); +} + +void RcxEditor::dismissAllPopups() { + if (m_historyPopup) static_cast(m_historyPopup)->dismiss(); + if (m_disasmPopup) static_cast(m_disasmPopup)->dismiss(); + if (m_structPreviewPopup) static_cast(m_structPreviewPopup)->dismiss(); } void RcxEditor::hideFindBar() { @@ -2503,10 +2401,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) { m_hoveredLine = -1; applyHoverHighlight(); // Dismiss hover popups so they get recreated with Set buttons once edit starts - if (m_historyPopup) - static_cast(m_historyPopup)->dismiss(); - if (m_structPreviewPopup) - static_cast(m_structPreviewPopup)->dismiss(); + dismissAllPopups(); // Clear editable-token color hints (de-emphasize non-active tokens) clearIndicatorLine(IND_EDITABLE, m_hintLine); m_hintLine = -1; @@ -3109,25 +3004,19 @@ void RcxEditor::applyHoverCursor() { } } if (!showPopup && m_historyPopup && m_historyPopup->isVisible()) - static_cast(m_historyPopup)->dismiss(); + static_cast(m_historyPopup)->dismiss(); } // Always dismiss disasm/preview popups during inline editing - if (m_disasmPopup && m_disasmPopup->isVisible()) - static_cast(m_disasmPopup)->dismiss(); - if (m_structPreviewPopup && m_structPreviewPopup->isVisible()) - static_cast(m_structPreviewPopup)->dismiss(); + if (m_disasmPopup) static_cast(m_disasmPopup)->dismiss(); + if (m_structPreviewPopup) static_cast(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_historyPopup && !m_applyingDocument) - static_cast(m_historyPopup)->dismiss(); - if (m_disasmPopup && !m_applyingDocument) - static_cast(m_disasmPopup)->dismiss(); - if (m_structPreviewPopup && !m_applyingDocument) - static_cast(m_structPreviewPopup)->dismiss(); + if (!m_applyingDocument) + dismissAllPopups(); m_sci->viewport()->setCursor(Qt::ArrowCursor); return; } @@ -3283,7 +3172,7 @@ void RcxEditor::applyHoverCursor() { } } if (!showPopup && m_historyPopup && m_historyPopup->isVisible()) - static_cast(m_historyPopup)->dismiss(); + static_cast(m_historyPopup)->dismiss(); } // Disasm / hex-dump popup on hover for FuncPtr and void Pointer nodes @@ -3348,8 +3237,8 @@ void RcxEditor::applyHoverCursor() { } if (!body.isEmpty()) { if (!m_disasmPopup) { - m_disasmPopup = new DisasmPopup(this); - static_cast(m_disasmPopup)->setOnMouseMove([this](QMouseEvent* e) { + m_disasmPopup = new TitleBodyPopup(this); + static_cast(m_disasmPopup)->setOnMouseMove([this](QMouseEvent* e) { QPoint gp = e->globalPosition().toPoint(); QPoint vp = m_sci->viewport()->mapFromGlobal(gp); m_lastHoverPos = vp; @@ -3367,10 +3256,11 @@ void RcxEditor::applyHoverCursor() { applyHoverCursor(); }); } - auto* popup = static_cast( + auto* popup = static_cast( m_disasmPopup); popup->populate(lm.nodeId, title, body, - editorFont()); + editorFont(), + ThemeManager::instance().current().syntaxNumber); long linePos = m_sci->SendScintilla( QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)h.line); @@ -3391,7 +3281,7 @@ void RcxEditor::applyHoverCursor() { showDisasm = true; // Dismiss value history popup to avoid fighting if (m_historyPopup && m_historyPopup->isVisible()) - static_cast(m_historyPopup)->dismiss(); + static_cast(m_historyPopup)->dismiss(); } } } @@ -3400,7 +3290,7 @@ void RcxEditor::applyHoverCursor() { } } if (!showDisasm && m_disasmPopup && m_disasmPopup->isVisible()) - static_cast(m_disasmPopup)->dismiss(); + static_cast(m_disasmPopup)->dismiss(); } // Struct preview popup for collapsed typed pointers @@ -3435,8 +3325,8 @@ void RcxEditor::applyHoverCursor() { } if (!body.isEmpty()) { if (!m_structPreviewPopup) { - m_structPreviewPopup = new StructPreviewPopup(this); - static_cast(m_structPreviewPopup)->setOnMouseMove([this](QMouseEvent* e) { + m_structPreviewPopup = new TitleBodyPopup(this); + static_cast(m_structPreviewPopup)->setOnMouseMove([this](QMouseEvent* e) { QPoint gp = e->globalPosition().toPoint(); QPoint vp = m_sci->viewport()->mapFromGlobal(gp); m_lastHoverPos = vp; @@ -3454,9 +3344,10 @@ void RcxEditor::applyHoverCursor() { applyHoverCursor(); }); } - auto* popup = static_cast(m_structPreviewPopup); + auto* popup = static_cast(m_structPreviewPopup); popup->populate(lm.nodeId, - lm.pointerTargetName, body, editorFont()); + lm.pointerTargetName, body, editorFont(), + ThemeManager::instance().current().text); long linePos = m_sci->SendScintilla( QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)h.line); @@ -3475,14 +3366,14 @@ void RcxEditor::applyHoverCursor() { popup->showAt(anchor, lh); showPreview = true; if (m_historyPopup && m_historyPopup->isVisible()) - static_cast(m_historyPopup)->dismiss(); + static_cast(m_historyPopup)->dismiss(); } } } } } if (!showPreview && m_structPreviewPopup && m_structPreviewPopup->isVisible()) - static_cast(m_structPreviewPopup)->dismiss(); + static_cast(m_structPreviewPopup)->dismiss(); } // Determine cursor shape based on interaction type diff --git a/src/editor.h b/src/editor.h index d8cc764..69ea4ee 100644 --- a/src/editor.h +++ b/src/editor.h @@ -37,6 +37,7 @@ public: void scrollToNodeId(uint64_t nodeId); void showFindBar(); void dismissHistoryPopup(); + void dismissAllPopups(); // ── Column span computation ── static ColumnSpan typeSpan(const LineMeta& lm, int typeW = kColType); @@ -155,8 +156,8 @@ private: // ── Value history ref (owned by controller) ── const QHash* m_valueHistory = nullptr; QWidget* m_historyPopup = nullptr; // ValueHistoryPopup (file-local class in editor.cpp) - QWidget* m_disasmPopup = nullptr; // DisasmPopup (file-local class in editor.cpp) - QWidget* m_structPreviewPopup = nullptr; // StructPreviewPopup (file-local class in editor.cpp) + QWidget* m_disasmPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp) + QWidget* m_structPreviewPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp) const Provider* m_disasmProvider = nullptr; // snapshot or real — for reading tree data const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses const NodeTree* m_disasmTree = nullptr; diff --git a/src/main.cpp b/src/main.cpp index ef0cf11..ff21543 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -299,6 +299,9 @@ public: // Kill the status bar item frame and panel border if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar) return; + // Kill Fusion's frame outline on QScintilla (window.darker(140) = ~#171717) + if (elem == PE_Frame && w && w->inherits("QsciScintilla")) + return; // Transparent menu bar background (no CSS needed) if (elem == PE_PanelMenuBar) return; @@ -835,6 +838,15 @@ void MainWindow::createMenus() { pane.editor->setRelativeOffsets(checked); }); + auto* actTypeHints = view->addAction("Type &Hints"); + actTypeHints->setCheckable(true); + actTypeHints->setChecked(settings.value("typeHints", false).toBool()); + connect(actTypeHints, &QAction::triggered, this, [this](bool checked) { + QSettings("Reclass", "Reclass").setValue("typeHints", checked); + for (auto& tab : m_tabs) + tab.ctrl->setTypeHints(checked); + }); + view->addSeparator(); view->addAction(m_workspaceDock->toggleViewAction()); { @@ -1233,6 +1245,7 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) { QSettings s("Reclass", "Reclass"); QString editorFont = s.value("font", "JetBrains Mono").toString(); pane.tabWidget->setStyleSheet(QStringLiteral( + "QTabWidget::pane { border: none; }" "QTabBar { border: none; }" "QTabBar::tab {" " background: %1; color: %2; padding: 0px 16px; border: none; border-radius: 0px; height: 24px;" @@ -1378,7 +1391,8 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) { // Sync status bar buttons if this is the active pane auto* tab = activeTab(); - if (tab && &tab->panes[tab->activePaneIdx] == p) + if (tab && tab->activePaneIdx >= 0 && tab->activePaneIdx < tab->panes.size() + && &tab->panes[tab->activePaneIdx] == p) syncViewButtons(p->viewMode); if (index == 1) { @@ -1599,6 +1613,7 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) { ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool()); ctrl->setTreeLines(QSettings("Reclass", "Reclass").value("treeLines", true).toBool()); ctrl->setBraceWrap(QSettings("Reclass", "Reclass").value("braceWrap", false).toBool()); + ctrl->setTypeHints(QSettings("Reclass", "Reclass").value("typeHints", false).toBool()); // Give every controller the shared document list for cross-tab type visibility ctrl->setProjectDocuments(&m_allDocs); @@ -2338,9 +2353,9 @@ void MainWindow::applyTheme(const Theme& theme) { // QWidget default colors are required because having ANY stylesheet on QMainWindow // switches children from palette-based to CSS-based rendering. setStyleSheet(QStringLiteral( - "QMainWindow::separator { width: 1px; height: 1px; background: transparent; }" + "QMainWindow::separator { width: 1px; height: 1px; background: %1; }" "QDockWidget { border: none; }" - "QDockWidget > QWidget { border: none; }")); + "QDockWidget > QWidget { border: none; }").arg(theme.background.name())); // Custom title bar — applied AFTER setStyleSheet() because the MainWindow // stylesheet re-resolves descendant palettes and would reset the QMenuBar palette. @@ -2384,6 +2399,7 @@ void MainWindow::applyTheme(const Theme& theme) { { QString editorFont = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString(); QString paneTabStyle = QStringLiteral( + "QTabWidget::pane { border: none; }" "QTabBar { border: none; }" "QTabBar::tab {" " background: %1; color: %2; padding: 0px 16px; border: none; border-radius: 0px; height: 24px;" @@ -2432,7 +2448,10 @@ void MainWindow::applyTheme(const Theme& theme) { m_workspaceTree->setStyleSheet(QStringLiteral( "QTreeView { background: %1; border: none; }" "QTreeView::branch:has-children:closed { image: url(:/chevron-right.svg); }" - "QTreeView::branch:has-children:open { image: url(:/chevron-down.svg); }") + "QTreeView::branch:has-children:open { image: url(:/chevron-down.svg); }" + "QAbstractScrollArea::corner { background: %1; border: none; }" + "QHeaderView { background: %1; border: none; }" + "QHeaderView::section { background: %1; border: none; }") .arg(theme.background.name())); m_workspaceTree->viewport()->update(); } @@ -2446,12 +2465,10 @@ void MainWindow::applyTheme(const Theme& theme) { theme.hover.name())); } - // Dock titlebar: restyle via palette + close button - if (m_dockTitleLabel) { - QPalette lp = m_dockTitleLabel->palette(); - lp.setColor(QPalette::WindowText, theme.textDim); - m_dockTitleLabel->setPalette(lp); - } + // Dock titlebar: restyle via stylesheet + close button + if (m_dockTitleLabel) + m_dockTitleLabel->setStyleSheet( + QStringLiteral("color: %1;").arg(theme.textDim.name())); if (auto* titleBar = m_workspaceDock ? m_workspaceDock->titleBarWidget() : nullptr) { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, theme.backgroundAlt); @@ -2466,16 +2483,14 @@ void MainWindow::applyTheme(const Theme& theme) { m_dockGrip->setGripColor(theme.textFaint); if (m_workspaceDock) m_workspaceDock->setStyleSheet(QStringLiteral( - "QDockWidget { border: 1px solid %1; }").arg(theme.border.name())); + "QDockWidget { border: 1px solid %1; border-right: none; }").arg(theme.border.name())); // Scanner dock if (m_scannerPanel) m_scannerPanel->applyTheme(theme); - if (m_scanDockTitle) { - QPalette lp = m_scanDockTitle->palette(); - lp.setColor(QPalette::WindowText, theme.textDim); - m_scanDockTitle->setPalette(lp); - } + if (m_scanDockTitle) + m_scanDockTitle->setStyleSheet( + QStringLiteral("color: %1;").arg(theme.textDim.name())); if (auto* titleBar = m_scannerDock ? m_scannerDock->titleBarWidget() : nullptr) { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, theme.backgroundAlt); @@ -3368,9 +3383,8 @@ void MainWindow::createWorkspaceDock() { m_dockTitleLabel = new QLabel("Project", titleBar); { - QPalette lp = m_dockTitleLabel->palette(); - lp.setColor(QPalette::WindowText, t.textDim); - m_dockTitleLabel->setPalette(lp); + m_dockTitleLabel->setStyleSheet( + QStringLiteral("color: %1;").arg(t.textDim.name())); QSettings s("Reclass", "Reclass"); QFont f(s.value("font", "JetBrains Mono").toString(), 10); f.setFixedPitch(true); @@ -3381,13 +3395,14 @@ void MainWindow::createWorkspaceDock() { layout->addStretch(); m_dockCloseBtn = new QToolButton(titleBar); - m_dockCloseBtn->setText(QStringLiteral("\u2715")); + m_dockCloseBtn->setIcon(QIcon(QStringLiteral(":/vsicons/close.svg"))); + m_dockCloseBtn->setIconSize(QSize(14, 14)); m_dockCloseBtn->setAutoRaise(true); m_dockCloseBtn->setCursor(Qt::PointingHandCursor); m_dockCloseBtn->setStyleSheet(QStringLiteral( - "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" - "QToolButton:hover { color: %2; }") - .arg(t.textDim.name(), t.indHoverSpan.name())); + "QToolButton { border: none; padding: 0px 4px; }" + "QToolButton:hover { background: %1; }") + .arg(t.hover.name())); connect(m_dockCloseBtn, &QToolButton::clicked, m_workspaceDock, &QDockWidget::close); layout->addWidget(m_dockCloseBtn); @@ -3395,10 +3410,11 @@ void MainWindow::createWorkspaceDock() { } // Outer border around entire dock (header + search + tree) + // background + ::title needed to suppress Fusion outline frame (renders ~#171717) { const auto& t = ThemeManager::instance().current(); m_workspaceDock->setStyleSheet(QStringLiteral( - "QDockWidget { border: 1px solid %1; }").arg(t.border.name())); + "QDockWidget { border: 1px solid %1; border-right: none; }").arg(t.border.name())); } // Container widget: search box + tree view @@ -3472,6 +3488,7 @@ void MainWindow::createWorkspaceDock() { m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers); m_workspaceTree->setExpandsOnDoubleClick(false); m_workspaceTree->setMouseTracking(true); + m_workspaceTree->setSelectionMode(QAbstractItemView::ExtendedSelection); { QSettings s("Reclass", "Reclass"); QFont f(s.value("font", "JetBrains Mono").toString(), 10); @@ -3501,7 +3518,10 @@ void MainWindow::createWorkspaceDock() { m_workspaceTree->setStyleSheet(QStringLiteral( "QTreeView { background: %1; border: none; }" "QTreeView::branch:has-children:closed { image: url(:/chevron-right.svg); }" - "QTreeView::branch:has-children:open { image: url(:/chevron-down.svg); }") + "QTreeView::branch:has-children:open { image: url(:/chevron-down.svg); }" + "QAbstractScrollArea::corner { background: %1; border: none; }" + "QHeaderView { background: %1; border: none; }" + "QHeaderView::section { background: %1; border: none; }") .arg(t.background.name())); } @@ -3509,10 +3529,10 @@ void MainWindow::createWorkspaceDock() { m_workspaceTree->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_workspaceTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) { - QModelIndex index = m_workspaceTree->indexAt(pos); + QModelIndex clickedIndex = m_workspaceTree->indexAt(pos); // Right-click on empty area → New Class / New Struct / New Enum - if (!index.isValid()) { + if (!clickedIndex.isValid()) { QMenu menu; auto* actClass = menu.addAction("New Class"); auto* actStruct = menu.addAction("New Struct"); @@ -3524,85 +3544,245 @@ void MainWindow::createWorkspaceDock() { return; } - auto structIdVar = index.data(Qt::UserRole + 1); - uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0; - if (structId == 0) return; + // If right-clicked item is not in current selection, select only it + auto* sel = m_workspaceTree->selectionModel(); + if (!sel->isSelected(clickedIndex)) + sel->select(clickedIndex, + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); - auto subVar = index.data(Qt::UserRole); - if (!subVar.isValid()) return; - auto* dock = static_cast(subVar.value()); - if (!dock || !m_tabs.contains(dock)) return; + // Gather all selected ROOT items (children are not independently actionable) + struct SelItem { + uint64_t structId; + QDockWidget* dock; + int nodeIdx; + QString keyword; + QString typeName; + }; + QVector items; - auto& tab = m_tabs[dock]; - int ni = tab.doc->tree.indexOfId(structId); - if (ni < 0) return; - QString kw = tab.doc->tree.nodes[ni].resolvedClassKeyword(); + for (const auto& idx : sel->selectedIndexes()) { + if (idx.parent().isValid()) continue; // skip children + auto idVar = idx.data(Qt::UserRole + 1); + uint64_t sid = idVar.isValid() ? idVar.toULongLong() : 0; + if (sid == 0) continue; + auto subVar = idx.data(Qt::UserRole); + if (!subVar.isValid()) continue; + auto* dk = static_cast(subVar.value()); + if (!dk || !m_tabs.contains(dk)) continue; + int ni = m_tabs[dk].doc->tree.indexOfId(sid); + if (ni < 0) continue; + const auto& nd = m_tabs[dk].doc->tree.nodes[ni]; + QString tn = nd.structTypeName.isEmpty() ? nd.name : nd.structTypeName; + if (tn.isEmpty()) tn = QStringLiteral("(unnamed)"); + items.append({sid, dk, ni, nd.resolvedClassKeyword(), tn}); + } + if (items.isEmpty()) return; QMenu menu; + + // Navigation actions (single selection only) + QAction* actOpenCurrent = nullptr; + QAction* actOpenNew = nullptr; + QAction* actDuplicate = nullptr; + if (items.size() == 1) { + actOpenCurrent = menu.addAction("Open in Current Tab"); + actOpenNew = menu.addAction("Open in New Tab"); + actDuplicate = menu.addAction("Duplicate"); + menu.addSeparator(); + } + + // Convert: only for single selection, class↔struct (not enum) QAction* actConvert = nullptr; - // class↔struct conversion only (no enum conversion) - if (kw == QStringLiteral("class")) - actConvert = menu.addAction("Convert to Struct"); - else if (kw == QStringLiteral("struct")) - actConvert = menu.addAction("Convert to Class"); - auto* actDelete = menu.addAction(QIcon(":/vsicons/remove.svg"), "Delete"); + if (items.size() == 1) { + if (items[0].keyword == QStringLiteral("class")) + actConvert = menu.addAction("Convert to Struct"); + else if (items[0].keyword == QStringLiteral("struct")) + actConvert = menu.addAction("Convert to Class"); + } + + // Delete: works for single or multi + QString delLabel = items.size() == 1 + ? QStringLiteral("Delete") + : QStringLiteral("Delete %1 items").arg(items.size()); + auto* actDelete = menu.addAction(QIcon(":/vsicons/remove.svg"), delLabel); QAction* chosen = menu.exec(m_workspaceTree->viewport()->mapToGlobal(pos)); - if (chosen == actDelete) { - QString typeName = tab.doc->tree.nodes[ni].structTypeName.isEmpty() - ? tab.doc->tree.nodes[ni].name - : tab.doc->tree.nodes[ni].structTypeName; - if (typeName.isEmpty()) typeName = QStringLiteral("(unnamed)"); - // Collect detailed reference info + if (chosen == actDelete) { + // Collect reference info across all selected items QStringList refDetails; - for (const auto& n : tab.doc->tree.nodes) { - if (n.refId == structId) { - QString ownerName; - uint64_t pid = n.parentId; - while (pid != 0) { - int pi = tab.doc->tree.indexOfId(pid); - if (pi < 0) break; - if (tab.doc->tree.nodes[pi].parentId == 0) { - ownerName = tab.doc->tree.nodes[pi].structTypeName.isEmpty() - ? tab.doc->tree.nodes[pi].name - : tab.doc->tree.nodes[pi].structTypeName; - break; + QStringList typeNames; + for (const auto& item : items) { + typeNames << item.typeName; + if (!m_tabs.contains(item.dock)) continue; + for (const auto& n : m_tabs[item.dock].doc->tree.nodes) { + if (n.refId == item.structId) { + QString ownerName; + uint64_t pid = n.parentId; + while (pid != 0) { + int pi = m_tabs[item.dock].doc->tree.indexOfId(pid); + if (pi < 0) break; + if (m_tabs[item.dock].doc->tree.nodes[pi].parentId == 0) { + const auto& pn = m_tabs[item.dock].doc->tree.nodes[pi]; + ownerName = pn.structTypeName.isEmpty() + ? pn.name : pn.structTypeName; + break; + } + pid = m_tabs[item.dock].doc->tree.nodes[pi].parentId; } - pid = tab.doc->tree.nodes[pi].parentId; + QString fieldDesc = ownerName.isEmpty() + ? n.name + : QStringLiteral("%1::%2").arg(ownerName, n.name); + refDetails << QStringLiteral(" \u2022 %1 (%2)") + .arg(fieldDesc, kindToString(n.kind)); } - QString fieldDesc = ownerName.isEmpty() - ? n.name - : QStringLiteral("%1::%2").arg(ownerName, n.name); - refDetails << QStringLiteral(" \u2022 %1 (%2)") - .arg(fieldDesc, kindToString(n.kind)); } } QString msg; - if (refDetails.isEmpty()) { - msg = QString("Delete '%1'?").arg(typeName); + if (items.size() == 1) { + msg = refDetails.isEmpty() + ? QStringLiteral("Delete '%1'?").arg(typeNames[0]) + : QStringLiteral("Delete '%1'?\n\n" + "The following %2 field(s) reference this type " + "and will become untyped (void):\n\n%3") + .arg(typeNames[0]) + .arg(refDetails.size()) + .arg(refDetails.join('\n')); } else { - msg = QString("Delete '%1'?\n\n" - "The following %2 field(s) reference this type " - "and will become untyped (void):\n\n%3") - .arg(typeName) - .arg(refDetails.size()) - .arg(refDetails.join('\n')); + msg = QStringLiteral("Delete %1 types?\n\n%2") + .arg(items.size()) + .arg(typeNames.join(QStringLiteral(", "))); + if (!refDetails.isEmpty()) + msg += QStringLiteral("\n\n%1 field(s) reference these types " + "and will become untyped (void):\n\n%2") + .arg(refDetails.size()) + .arg(refDetails.join('\n')); } auto answer = QMessageBox::question(this, "Delete Type", msg, QMessageBox::Yes | QMessageBox::No, QMessageBox::No); if (answer != QMessageBox::Yes) return; - tab.ctrl->deleteRootStruct(structId); + // Group deletes by controller for single undo macro per document + QHash> byCtrl; + for (const auto& item : items) { + if (!m_tabs.contains(item.dock)) continue; + byCtrl[m_tabs[item.dock].ctrl].append(item.structId); + } + for (auto it = byCtrl.begin(); it != byCtrl.end(); ++it) { + auto* ctrl = it.key(); + const auto& ids = it.value(); + if (ids.size() == 1) { + ctrl->deleteRootStruct(ids[0]); + } else { + // Wrap multiple deletes in a single undo macro + ctrl->document()->undoStack.beginMacro( + QStringLiteral("Delete %1 types").arg(ids.size())); + for (uint64_t sid : ids) + ctrl->deleteRootStruct(sid); + ctrl->document()->undoStack.endMacro(); + } + } rebuildWorkspaceModel(); - } else if (chosen && chosen == actConvert) { - QString newKw = kw == QStringLiteral("class") - ? QStringLiteral("struct") : QStringLiteral("class"); - QString oldKw = tab.doc->tree.nodes[ni].resolvedClassKeyword(); + + } else if (chosen && chosen == actOpenCurrent && items.size() == 1) { + // Open in current (active) tab — set viewRootId on active editor + const auto& item = items[0]; + if (!m_tabs.contains(item.dock)) return; + RcxDocument* doc = m_tabs[item.dock].doc; + doc->tree.nodes[item.nodeIdx].collapsed = false; + + // Use the active tab if it shares the same document, else use owner + QDockWidget* targetDock = item.dock; + if (m_activeDocDock && m_tabs.contains(m_activeDocDock) + && m_tabs[m_activeDocDock].doc == doc) + targetDock = m_activeDocDock; + + auto& tab = m_tabs[targetDock]; + tab.ctrl->setViewRootId(item.structId); + tab.ctrl->refresh(); + targetDock->raise(); + targetDock->show(); + m_activeDocDock = targetDock; + QString structName = doc->tree.nodes[item.nodeIdx].structTypeName.isEmpty() + ? doc->tree.nodes[item.nodeIdx].name + : doc->tree.nodes[item.nodeIdx].structTypeName; + if (!structName.isEmpty()) + targetDock->setWindowTitle(structName); + + } else if (chosen && chosen == actOpenNew && items.size() == 1) { + // Open in a brand new tab (sharing the same document) + const auto& item = items[0]; + if (!m_tabs.contains(item.dock)) return; + RcxDocument* doc = m_tabs[item.dock].doc; + doc->tree.nodes[item.nodeIdx].collapsed = false; + auto* newDock = createTab(doc); + m_tabs[newDock].ctrl->setViewRootId(item.structId); + m_tabs[newDock].ctrl->refresh(); + QString structName = doc->tree.nodes[item.nodeIdx].structTypeName.isEmpty() + ? doc->tree.nodes[item.nodeIdx].name + : doc->tree.nodes[item.nodeIdx].structTypeName; + if (!structName.isEmpty()) + newDock->setWindowTitle(structName); + rebuildWorkspaceModel(); + + } else if (chosen && chosen == actDuplicate && items.size() == 1) { + // Duplicate: deep-copy the struct as a new root with a unique name + const auto& item = items[0]; + if (!m_tabs.contains(item.dock)) return; + auto& tab = m_tabs[item.dock]; + auto& tree = tab.doc->tree; + + // Generate unique name + QString baseName = item.typeName + QStringLiteral("_copy"); + QString newName = baseName; + int counter = 1; + QSet existing; + for (const auto& n : tree.nodes) + if (n.kind == rcx::NodeKind::Struct && !n.structTypeName.isEmpty()) + existing.insert(n.structTypeName); + while (existing.contains(newName)) + newName = baseName + QString::number(counter++); + + tab.ctrl->setSuppressRefresh(true); + tab.doc->undoStack.beginMacro(QStringLiteral("Duplicate ") + item.typeName); + + // Clone root node + rcx::Node root = tree.nodes[item.nodeIdx]; + root.id = tree.reserveId(); + root.structTypeName = newName; + root.name = newName; + root.parentId = 0; tab.doc->undoStack.push(new rcx::RcxCommand(tab.ctrl, - rcx::cmd::ChangeClassKeyword{structId, oldKw, newKw})); + rcx::cmd::Insert{root})); + + // Clone children (re-lookup after insert since indices may shift) + QVector children = tree.childrenOf(item.structId); + for (int ci : children) { + rcx::Node child = tree.nodes[ci]; + child.id = tree.reserveId(); + child.parentId = root.id; + child.refId = 0; // don't copy pointer refs + tab.doc->undoStack.push(new rcx::RcxCommand(tab.ctrl, + rcx::cmd::Insert{child})); + } + + tab.doc->undoStack.endMacro(); + tab.ctrl->setSuppressRefresh(false); + tab.ctrl->refresh(); + rebuildWorkspaceModel(); + + } else if (chosen && chosen == actConvert && items.size() == 1) { + const auto& item = items[0]; + if (!m_tabs.contains(item.dock)) return; + auto& tab = m_tabs[item.dock]; + int ni = tab.doc->tree.indexOfId(item.structId); + if (ni < 0) return; + QString newKw = item.keyword == QStringLiteral("class") + ? QStringLiteral("struct") : QStringLiteral("class"); + tab.doc->undoStack.push(new rcx::RcxCommand(tab.ctrl, + rcx::cmd::ChangeClassKeyword{item.structId, item.keyword, newKw})); rebuildWorkspaceModel(); } }); @@ -3651,9 +3831,10 @@ void MainWindow::createWorkspaceDock() { if (pi >= 0) tree.nodes[pi].collapsed = false; tab.ctrl->setViewRootId(parentId); tab.ctrl->scrollToNodeId(structId); - QTimer::singleShot(0, this, [this, ownerDock]() { - if (!m_tabs.contains(ownerDock)) return; - auto& t = m_tabs[ownerDock]; + QPointer dockRef = ownerDock; + QTimer::singleShot(0, this, [this, dockRef]() { + if (!dockRef || !m_tabs.contains(dockRef)) return; + auto& t = m_tabs[dockRef]; if (t.activePaneIdx >= 0 && t.activePaneIdx < t.panes.size()) { auto& p = t.panes[t.activePaneIdx]; if (p.viewMode == VM_Rendered) updateRenderedView(t, p); @@ -3684,6 +3865,59 @@ void MainWindow::createWorkspaceDock() { newDock->setWindowTitle(structName); rebuildWorkspaceModel(); }); + + // Single-click: peek (raise existing tab / scroll to member) — no new tab creation + connect(m_workspaceTree, &QTreeView::clicked, this, [this](const QModelIndex& index) { + // Modifier held → user is multi-selecting, don't navigate + if (QApplication::keyboardModifiers() & (Qt::ControlModifier | Qt::ShiftModifier)) + return; + + auto structIdVar = index.data(Qt::UserRole + 1); + uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0; + if (structId == 0) return; + + auto subVar = index.data(Qt::UserRole); + if (!subVar.isValid()) return; + auto* ownerDock = static_cast(subVar.value()); + if (!ownerDock || !m_tabs.contains(ownerDock)) return; + + RcxDocument* doc = m_tabs[ownerDock].doc; + auto& tree = doc->tree; + int ni = tree.indexOfId(structId); + if (ni < 0) return; + + uint64_t parentId = tree.nodes[ni].parentId; + if (parentId != 0) { + // Child member: navigate within owner tab, scroll to member + ownerDock->raise(); + ownerDock->show(); + m_activeDocDock = ownerDock; + auto& tab = m_tabs[ownerDock]; + int pi = tree.indexOfId(parentId); + if (pi >= 0) tree.nodes[pi].collapsed = false; + tab.ctrl->setViewRootId(parentId); + tab.ctrl->scrollToNodeId(structId); + QPointer dockRef = ownerDock; + QTimer::singleShot(0, this, [this, dockRef]() { + if (!dockRef || !m_tabs.contains(dockRef)) return; + auto& t = m_tabs[dockRef]; + if (t.activePaneIdx >= 0 && t.activePaneIdx < t.panes.size()) { + auto& p = t.panes[t.activePaneIdx]; + if (p.viewMode == VM_Rendered) updateRenderedView(t, p); + } + }); + } else { + // Root item: raise existing tab if one views this struct (peek only) + for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { + if (it->doc == doc && it->ctrl->viewRootId() == structId) { + it.key()->raise(); + it.key()->show(); + m_activeDocDock = it.key(); + return; + } + } + } + }); } // ── Scanner Dock ── @@ -3717,11 +3951,8 @@ void MainWindow::createScannerDock() { layout->addWidget(m_scanDockGrip); m_scanDockTitle = new QLabel("Scanner", titleBar); - { - QPalette lp = m_scanDockTitle->palette(); - lp.setColor(QPalette::WindowText, t.textDim); - m_scanDockTitle->setPalette(lp); - } + m_scanDockTitle->setStyleSheet( + QStringLiteral("color: %1;").arg(t.textDim.name())); layout->addWidget(m_scanDockTitle); layout->addStretch(); @@ -3852,6 +4083,16 @@ void MainWindow::rebuildWorkspaceModelNow() { } rcx::syncProjectExplorer(m_workspaceModel, tabs); + // Mark items that are currently viewed in a tab + QSet viewedIds; + for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) + viewedIds.insert(it->ctrl->viewRootId()); + for (int i = 0; i < m_workspaceModel->rowCount(); ++i) { + auto* item = m_workspaceModel->item(i); + uint64_t id = item->data(Qt::UserRole + 1).toULongLong(); + item->setData(viewedIds.contains(id), Qt::UserRole + 3); + } + if (m_dockTitleLabel) { int structs = 0, enums = 0; for (int i = 0; i < m_workspaceModel->rowCount(); ++i) { @@ -4202,6 +4443,24 @@ int main(int argc, char* argv[]) { window.show(); + // --screenshot : open default project, grab window, save, exit + { + QStringList args = app.arguments(); + int ssIdx = args.indexOf("--screenshot"); + if (ssIdx >= 0 && ssIdx + 1 < args.size()) { + QString ssPath = args[ssIdx + 1]; + QMetaObject::invokeMethod(&window, [&window, ssPath]() { + window.project_new(); + QTimer::singleShot(500, &window, [&window, ssPath]() { + QPixmap px = window.grab(); + px.save(ssPath); + qApp->quit(); + }); + }, Qt::QueuedConnection); + return app.exec(); + } + } + // Show VS2022-style start page instead of jumping straight to demo QMetaObject::invokeMethod(&window, "showStartPage", Qt::QueuedConnection); diff --git a/src/typeinfer.h b/src/typeinfer.h new file mode 100644 index 0000000..b9af142 --- /dev/null +++ b/src/typeinfer.h @@ -0,0 +1,499 @@ +#pragma once +#include +#include +#include +#include + +#include "core.h" + +namespace rcx { + +// ── Hints from value history (optional, improves accuracy) ── + +struct InferHints { + const uint8_t* minObserved = nullptr; // raw bytes, same len as data + const uint8_t* maxObserved = nullptr; + bool monotonic = false; // value only increases or only decreases + bool neverChanged = false; // identical across all samples + int sampleCount = 0; // 0 = no history + int ptrSize = 8; +}; + +// ── Suggestion result ── + +struct TypeSuggestion { + QVector kinds; // size==1: convert, size>1: uniform split + int score = 0; // 0-100 feature ratio (passed / checked × 100) + int strength = 0; // 0=hidden, 1=weak, 2=moderate, 3=strong +}; + +// ── Public API ── + +QVector inferTypes( + const uint8_t* data, int len, + const InferHints& hints = {}, + int maxResults = 3); + +// Format top suggestion as short display string (e.g. "Float×2", "Int32", "UTF8") +inline QString formatHint(const TypeSuggestion& s) { + if (s.kinds.isEmpty()) return {}; + const char* name = kindMeta(s.kinds[0])->typeName; + QString base = (s.kinds.size() == 1) + ? QString::fromLatin1(name) + : QStringLiteral("%1\u00D7%2").arg(QString::fromLatin1(name)).arg(s.kinds.size()); + if (s.strength <= 2) base += QLatin1Char('?'); // moderate gets ? + return base; +} + +// ── Implementation (header-only) ── + +namespace detail { + +inline uint32_t loadU32(const uint8_t* p) { + uint32_t v; std::memcpy(&v, p, 4); return v; +} +inline uint64_t loadU64(const uint8_t* p) { + uint64_t v; std::memcpy(&v, p, 8); return v; +} +inline uint16_t loadU16(const uint8_t* p) { + uint16_t v; std::memcpy(&v, p, 2); return v; +} +inline float loadF32(const uint8_t* p) { + float v; std::memcpy(&v, p, 4); return v; +} +inline double loadF64(const uint8_t* p) { + double v; std::memcpy(&v, p, 8); return v; +} + +inline bool allZero(const uint8_t* p, int n) { + for (int i = 0; i < n; ++i) if (p[i]) return false; + return true; +} + +inline int popcount32(uint32_t v) { +#if defined(__GNUC__) || defined(__clang__) + return __builtin_popcount(v); +#else + int c = 0; while (v) { v &= v - 1; ++c; } return c; +#endif +} + +inline bool isPrintable(uint8_t c) { + return c >= 0x20 && c <= 0x7E; +} + +// ── Float feature checker ── +// Returns features passed out of features checked (as pair) + +struct FeatureResult { int passed; int checked; }; + +inline bool isGoodFloat(uint32_t bits) { + uint32_t exp = (bits >> 23) & 0xFF; + if (exp == 0xFF) return false; // inf/nan + if (exp == 0 && (bits & 0x7FFFFF)) return false; // denormal + float f; std::memcpy(&f, &bits, 4); + double af = std::fabs((double)f); + return f == 0.0f || (af >= 1e-6 && af <= 1e7); +} + +inline FeatureResult countFloatFeatures(uint32_t cur, + const uint8_t* minP, const uint8_t* maxP, + const InferHints& h) { + int passed = 0, checked = 4; + float f; std::memcpy(&f, &cur, 4); + + // Feature 1: finite + passed += std::isfinite((double)f) ? 1 : 0; + // Feature 2: non-denormal (exponent > 0 or value is ±0) + uint32_t exp = (cur >> 23) & 0xFF; + passed += (exp > 0 || (cur & 0x7FFFFFFF) == 0) ? 1 : 0; + // Feature 3: reasonable range + double af = std::fabs((double)f); + passed += (f == 0.0f || (af >= 1e-6 && af <= 1e7)) ? 1 : 0; + // Feature 4: has fractional part (not just a reinterpreted integer) + float ip; double frac = std::fabs((double)std::modf(f, &ip)); + passed += (frac > 0.0001) ? 1 : 0; + + if (h.sampleCount > 0 && minP && maxP) { + checked += 4; + uint32_t minBits = loadU32(minP), maxBits = loadU32(maxP); + // Feature 5-6: min/max are also valid floats + passed += isGoodFloat(minBits) ? 1 : 0; + passed += isGoodFloat(maxBits) ? 1 : 0; + // Feature 7: field changes + passed += (minBits != maxBits) ? 1 : 0; + // Feature 8: range is game-plausible + float fmin, fmax; + std::memcpy(&fmin, &minBits, 4); + std::memcpy(&fmax, &maxBits, 4); + double range = std::fabs((double)fmax - (double)fmin); + passed += (range < 1e6) ? 1 : 0; + } + return {passed, checked}; +} + +// ── Integer feature checker ── + +inline FeatureResult countIntFeatures(uint32_t val, + const uint8_t* minP, const uint8_t* maxP, + const InferHints& h) { + int passed = 0, checked = 3; + int32_t sv = (int32_t)val; + + // Feature 1: non-zero + passed += (val != 0) ? 1 : 0; + // Feature 2: small absolute value + passed += (val <= 1000000u || (uint32_t)(sv + 1000000) <= 2000000u) ? 1 : 0; + // Feature 3: fits int16 range + passed += (sv >= -32768 && sv <= 32767) ? 1 : 0; + + if (h.sampleCount > 0 && minP && maxP) { + checked += 3; + uint32_t minV = loadU32(minP), maxV = loadU32(maxP); + // Feature 4: min/max in reasonable range + passed += (minV <= 1000000u && maxV <= 1000000u) ? 1 : 0; + // Feature 5: monotonic (counter/timer) + passed += h.monotonic ? 1 : 0; + // Feature 6: field varies + passed += (minV != maxV) ? 1 : 0; + } + return {passed, checked}; +} + +// ── Flags feature checker ── + +inline FeatureResult countFlagFeatures(uint32_t val, + const uint8_t* minP, const uint8_t* maxP, + const InferHints& h) { + int passed = 0, checked = 2; + int pc = popcount32(val); + + // Feature 1: sparse bits (1-3 set) + passed += (pc >= 1 && pc <= 3) ? 1 : 0; + // Feature 2: not a small sequential integer (flags are usually not 1,2,3...) + passed += (val > 256 || (val & (val - 1)) != 0) ? 1 : 0; + + if (h.sampleCount > 0 && minP && maxP) { + checked += 3; + uint32_t minV = loadU32(minP), maxV = loadU32(maxP); + // Feature 3: XOR of min/max has low popcount (specific bits toggle) + passed += (popcount32(minV ^ maxV) <= 4) ? 1 : 0; + // Feature 4: field varies + passed += (minV != maxV) ? 1 : 0; + // Feature 5: max is superset of min bits + passed += ((minV & maxV) == minV) ? 1 : 0; + } + return {passed, checked}; +} + +// ── Pointer feature checker ── + +inline FeatureResult countPtrFeatures64(uint64_t val) { + int passed = 0, checked = 5; + // Feature 1: non-zero and not common sentinel values + passed += (val != 0 && val != 0xFFFFFFFFFFFFFFFFULL + && val != 0x00000000FFFFFFFFULL) ? 1 : 0; + // Feature 2: canonical 48-bit address (sign-extended from bit 47) + passed += (val <= 0x00007FFFFFFFFFFFULL + || val >= 0xFFFF800000000000ULL) ? 1 : 0; + // Feature 3: aligned to 8 (heap/vtable allocations) + passed += ((val & 7) == 0) ? 1 : 0; + // Feature 4: above null guard pages (real addresses >= 64KB) + passed += (val >= 0x10000) ? 1 : 0; + // Feature 5: has upper 32 bits (real 64-bit address, not a small constant) + passed += ((val >> 32) != 0) ? 1 : 0; + return {passed, checked}; +} + +inline FeatureResult countPtrFeatures32(uint32_t val) { + int passed = 0, checked = 3; + // Feature 1: non-zero and not sentinel + passed += (val != 0 && val != 0xFFFFFFFF) ? 1 : 0; + // Feature 2: aligned to 4 + passed += ((val & 3) == 0) ? 1 : 0; + // Feature 3: above null guard pages (>= 64KB) + passed += (val >= 0x10000) ? 1 : 0; + return {passed, checked}; +} + +// ── String feature checker ── + +inline FeatureResult countStringFeatures(const uint8_t* data, int len) { + if (len < 2) return {0, 4}; + int printable = 0, letters = 0, consecutive = 0, maxConsec = 0; + for (int i = 0; i < len; ++i) { + if (isPrintable(data[i])) { + printable++; + consecutive++; + maxConsec = std::max(maxConsec, consecutive); + if ((data[i] >= 'A' && data[i] <= 'Z') || (data[i] >= 'a' && data[i] <= 'z')) + letters++; + } else { + consecutive = 0; + } + } + double ratio = (double)printable / len; + int passed = 0, checked = 4; + passed += (maxConsec >= 4) ? 1 : 0; + passed += (ratio > 0.75) ? 1 : 0; + passed += (letters >= 1) ? 1 : 0; + passed += (ratio > 0.90) ? 1 : 0; + return {passed, checked}; +} + +// ── Int16 feature checker ── + +inline FeatureResult countInt16Features(uint16_t val, + const uint8_t* minP, const uint8_t* maxP, + const InferHints& h) { + int passed = 0, checked = 2; + int16_t sv = (int16_t)val; + passed += (val != 0) ? 1 : 0; + passed += (sv >= -4096 && sv <= 4096) ? 1 : 0; + + if (h.sampleCount > 0 && minP && maxP) { + checked += 2; + uint16_t minV = loadU16(minP), maxV = loadU16(maxP); + passed += (minV <= 4096 && maxV <= 4096) ? 1 : 0; + passed += (minV != maxV) ? 1 : 0; + } + return {passed, checked}; +} + +// ── Score from feature result ── + +inline int featureScore(FeatureResult r) { + if (r.checked == 0) return 0; + return (r.passed * 100) / r.checked; +} + +inline int strengthFromScore(int score) { + if (score >= 75) return 3; + if (score >= 50) return 2; + if (score >= 25) return 1; + return 0; +} + +// ── Candidate accumulator ── + +struct Candidate { + QVector kinds; + int score; +}; + +inline void addCandidate(QVector& out, NodeKind k, int score) { + if (score >= 25) out.append({{k}, score}); +} + +inline void addSplitCandidate(QVector& out, NodeKind k, int count, int score) { + if (score >= 25) { + QVector kinds(count, k); + out.append({std::move(kinds), score}); + } +} + +// ── Try whole-width interpretations ── + +inline void tryWhole8(const uint8_t* data, const InferHints& h, QVector& out) { + uint64_t u64 = loadU64(data); + + // Pointer64 + if (h.ptrSize == 8) + addCandidate(out, NodeKind::Pointer64, featureScore(countPtrFeatures64(u64))); + + // Double + { + double d; std::memcpy(&d, data, 8); + uint64_t exp = (u64 >> 52) & 0x7FF; + int passed = 0, checked = 3; + passed += std::isfinite(d) ? 1 : 0; + passed += (exp > 0 || (u64 & 0x7FFFFFFFFFFFFFFFull) == 0) ? 1 : 0; + double ad = std::fabs(d); + passed += (d == 0.0 || (ad >= 1e-6 && ad <= 1e12)) ? 1 : 0; + addCandidate(out, NodeKind::Double, featureScore({passed, checked})); + } + + // UTF8 + addCandidate(out, NodeKind::UTF8, featureScore(countStringFeatures(data, 8))); + + // UInt64 / Int64 + { + int passed = 0, checked = 4; + // Feature 1: fits in 32 bits (small constant, not an address) + passed += (u64 <= 0xFFFFFFFFull) ? 1 : 0; + // Feature 2: upper 32 bits are zero (confirms it's a small value, not a pointer) + passed += ((u64 >> 32) == 0) ? 1 : 0; + // Feature 3: non-zero + passed += (u64 != 0) ? 1 : 0; + // Feature 4: monotonic or very small (< 0x10000) + passed += (h.monotonic || u64 < 0x10000) ? 1 : 0; + addCandidate(out, NodeKind::UInt64, featureScore({passed, checked})); + } +} + +inline void tryWhole4(const uint8_t* data, const uint8_t* minP, const uint8_t* maxP, + const InferHints& h, QVector& out) { + uint32_t u32 = loadU32(data); + + // Float + addCandidate(out, NodeKind::Float, featureScore(countFloatFeatures(u32, minP, maxP, h))); + + // Int32 + addCandidate(out, NodeKind::Int32, featureScore(countIntFeatures(u32, minP, maxP, h))); + + // UInt32 + addCandidate(out, NodeKind::UInt32, featureScore(countIntFeatures(u32, minP, maxP, h))); + + // Flags (only if sparse bits) + addCandidate(out, NodeKind::UInt32, featureScore(countFlagFeatures(u32, minP, maxP, h))); + + // Pointer32 + if (h.ptrSize == 4) + addCandidate(out, NodeKind::Pointer32, featureScore(countPtrFeatures32(u32))); +} + +inline void tryWhole2(const uint8_t* data, const uint8_t* minP, const uint8_t* maxP, + const InferHints& h, QVector& out) { + uint16_t u16 = loadU16(data); + int scoreI = featureScore(countInt16Features(u16, minP, maxP, h)); + addCandidate(out, NodeKind::Int16, scoreI); + addCandidate(out, NodeKind::UInt16, scoreI); +} + +inline void tryWhole1(const uint8_t* data, QVector& out) { + uint8_t v = data[0]; + int score = (v == 0 || v == 1) ? 50 : 25; + addCandidate(out, NodeKind::UInt8, score); + if (v <= 1) addCandidate(out, NodeKind::Bool, 60); +} + +// ── Try uniform splits ── + +inline void trySplitUniform(const uint8_t* data, int len, + const InferHints& h, + QVector& out) { + + // 8 → 2×4 + if (len == 8) { + const uint8_t* minA = h.minObserved; + const uint8_t* minB = h.minObserved ? h.minObserved + 4 : nullptr; + const uint8_t* maxA = h.maxObserved; + const uint8_t* maxB = h.maxObserved ? h.maxObserved + 4 : nullptr; + bool zA = allZero(data, 4), zB = allZero(data + 4, 4); + + // Float×2: both halves must be good floats and at least one non-zero + if (!zA || !zB) { + uint32_t bitsA = loadU32(data), bitsB = loadU32(data + 4); + bool fA = zA || isGoodFloat(bitsA); + bool fB = zB || isGoodFloat(bitsB); + if (fA && fB) { + auto rA = zA ? FeatureResult{2, 4} : countFloatFeatures(bitsA, minA, maxA, h); + auto rB = zB ? FeatureResult{2, 4} : countFloatFeatures(bitsB, minB, maxB, h); + int score = std::min(featureScore(rA), featureScore(rB)); + addSplitCandidate(out, NodeKind::Float, 2, score); + } + } + + // Int32×2: both halves, at least one non-zero + if (!zA || !zB) { + auto rA = zA ? FeatureResult{1, 3} : countIntFeatures(loadU32(data), minA, maxA, h); + auto rB = zB ? FeatureResult{1, 3} : countIntFeatures(loadU32(data + 4), minB, maxB, h); + int score = std::min(featureScore(rA), featureScore(rB)); + addSplitCandidate(out, NodeKind::Int32, 2, score); + } + + // UInt32×2 + if (!zA || !zB) { + auto rA = zA ? FeatureResult{1, 3} : countIntFeatures(loadU32(data), minA, maxA, h); + auto rB = zB ? FeatureResult{1, 3} : countIntFeatures(loadU32(data + 4), minB, maxB, h); + int score = std::min(featureScore(rA), featureScore(rB)); + addSplitCandidate(out, NodeKind::UInt32, 2, score); + } + } + + // 8 → 4×2 or 4 → 2×2 + int halfLen = len / 2; + if (halfLen == 2) { + int minScore = 100; + int count = len / 2; + bool anyNonZero = false; + for (int i = 0; i < count; ++i) { + const uint8_t* part = data + i * 2; + if (!allZero(part, 2)) anyNonZero = true; + const uint8_t* mp = h.minObserved ? h.minObserved + i * 2 : nullptr; + const uint8_t* xp = h.maxObserved ? h.maxObserved + i * 2 : nullptr; + int s = featureScore(countInt16Features(loadU16(part), mp, xp, h)); + minScore = std::min(minScore, s); + } + if (anyNonZero) { + addSplitCandidate(out, NodeKind::Int16, count, minScore); + addSplitCandidate(out, NodeKind::UInt16, count, minScore); + } + } +} + +// ── Prune and rank ── + +inline QVector pruneAndRank(QVector& cands, int maxResults) { + // Sort descending by score + std::sort(cands.begin(), cands.end(), [](const Candidate& a, const Candidate& b) { + return a.score > b.score; + }); + + // Dedup: keep highest-scoring per unique kinds vector + QVector deduped; + for (const auto& c : cands) { + bool dup = false; + for (const auto& d : deduped) { + if (d.kinds == c.kinds) { dup = true; break; } + } + if (!dup) deduped.append(c); + } + + // Dominance: if top >= 1.5× second, keep only top + if (deduped.size() >= 2 && deduped[0].score >= deduped[1].score * 3 / 2) + deduped.resize(1); + else if (deduped.size() > maxResults) + deduped.resize(maxResults); + + QVector result; + result.reserve(deduped.size()); + for (const auto& c : deduped) { + int str = strengthFromScore(c.score); + if (str > 0) + result.append({c.kinds, c.score, str}); + } + return result; +} + +} // namespace detail + +// ── Entry point ── + +inline QVector inferTypes( + const uint8_t* data, int len, + const InferHints& hints, + int maxResults) +{ + using namespace detail; + + if (!data || len <= 0) return {}; + if (allZero(data, len)) return {}; // NULL → skip entirely + + QVector cands; + cands.reserve(12); + + // Whole-width candidates + if (len >= 8) tryWhole8(data, hints, cands); + if (len == 4) tryWhole4(data, hints.minObserved, hints.maxObserved, hints, cands); + if (len == 2) tryWhole2(data, hints.minObserved, hints.maxObserved, hints, cands); + if (len == 1) tryWhole1(data, cands); + + // Uniform splits (compete directly with whole-width candidates) + if (len >= 4) + trySplitUniform(data, len, hints, cands); + + return pruneAndRank(cands, maxResults); +} + +} // namespace rcx diff --git a/src/workspace_model.h b/src/workspace_model.h index 9fb9089..cea4825 100644 --- a/src/workspace_model.h +++ b/src/workspace_model.h @@ -236,12 +236,14 @@ public: // Draw icon for top-level items if (!isChild) { + bool viewed = index.data(Qt::UserRole + 3).toBool(); QVariant iconVar = index.data(Qt::DecorationRole); if (iconVar.isValid()) { QIcon icon = iconVar.value(); int iconSz = opt.fontMetrics.height(); int iconY = textRect.y() + (textRect.height() - iconSz) / 2; - icon.paint(painter, textRect.x(), iconY, iconSz, iconSz); + icon.paint(painter, QRect(textRect.x(), iconY, iconSz, iconSz), + Qt::AlignCenter, viewed ? QIcon::Normal : QIcon::Disabled); textRect.setLeft(textRect.left() + iconSz + 4); } } diff --git a/tests/test_pixels.py b/tests/test_pixels.py new file mode 100644 index 0000000..9a6430b --- /dev/null +++ b/tests/test_pixels.py @@ -0,0 +1,92 @@ +""" +Pixel boundary test: validates no Fusion outline leak at the workspace→editor seam. + +Usage: + python tests/test_pixels.py [screenshot.png] + +If no screenshot given, launches Reclass.exe --screenshot to grab one. +Scans for the specific Fusion outline artifact: color (23,23,23) which is +window.darker(140) for the VS2022 Dark theme background #1e1e1e. +""" +import sys, os, subprocess +from PIL import Image +from collections import defaultdict + +GRAB_PATH = os.path.join("build", "test_grab.png") + +def get_screenshot(path): + if not os.path.exists(path): + print(f"Launching Reclass.exe --screenshot {path}") + subprocess.run(["./build/Reclass.exe", "--screenshot", path], + timeout=15, check=True) + return Image.open(path) + +def scan_for_artifact(img): + """Scan entire image for the Fusion outline color (23,23,23). + Also find all near-black pixels (< 28,28,28) that aren't the + theme background (30,30,30).""" + w, h = img.size + px = img.load() + + target = (23, 23, 23) + bg = (30, 30, 30) + + target_hits = [] + dark_hits = defaultdict(list) # color → [(x,y), ...] + + for y in range(h): + for x in range(w): + r, g, b = px[x, y][:3] + if r == target[0] and g == target[1] and b == target[2]: + target_hits.append((x, y)) + elif r < 28 and g < 28 and b < 28 and (r, g, b) != (0, 0, 0): + # Near-black but not pure black (text anti-aliasing) and not bg + dark_hits[(r, g, b)].append((x, y)) + + return target_hits, dark_hits + +def summarize_region(hits): + """Summarize a list of (x,y) hits.""" + if not hits: + return "none" + xs = [p[0] for p in hits] + ys = [p[1] for p in hits] + return (f"{len(hits)}px x=[{min(xs)}..{max(xs)}] y=[{min(ys)}..{max(ys)}] " + f"size={max(xs)-min(xs)+1}x{max(ys)-min(ys)+1}") + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else GRAB_PATH + img = get_screenshot(path) + w, h = img.size + print(f"Image: {w}x{h}") + + target_hits, dark_hits = scan_for_artifact(img) + + print(f"\n(23,23,23) Fusion outline pixels: {summarize_region(target_hits)}") + + if dark_hits: + print(f"\nOther near-black pixels (< 28,28,28):") + for c, positions in sorted(dark_hits.items(), key=lambda t: -len(t[1])): + print(f" ({c[0]:3},{c[1]:3},{c[2]:3}): {summarize_region(positions)}") + + if target_hits: + # Show row distribution (condensed) + rows = defaultdict(list) + for x, y in target_hits: + rows[y].append(x) + print(f"\n(23,23,23) row detail:") + for y in sorted(rows.keys()): + xs = sorted(rows[y]) + if len(xs) > 5: + print(f" y={y}: {len(xs)}px x=[{xs[0]}..{xs[-1]}]") + else: + print(f" y={y}: {len(xs)}px x={xs}") + + print(f"\nFAIL: Found {len(target_hits)} Fusion outline pixels (23,23,23)") + sys.exit(1) + else: + print("\nPASS: No Fusion outline artifact found") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/tests/test_typeinfer.cpp b/tests/test_typeinfer.cpp new file mode 100644 index 0000000..7ee224d --- /dev/null +++ b/tests/test_typeinfer.cpp @@ -0,0 +1,189 @@ +#include +#include +#include "typeinfer.h" + +using namespace rcx; + +class TestTypeInfer : public QObject { + Q_OBJECT +private slots: + + // ── NULL / zero → empty ── + + void nullPtr() { + QVERIFY(inferTypes(nullptr, 8).isEmpty()); + } + void zeroLen() { + uint8_t d[4] = {}; + QVERIFY(inferTypes(d, 0).isEmpty()); + } + void allZeros8() { + uint8_t d[8] = {}; + QVERIFY(inferTypes(d, 8).isEmpty()); + } + void allZeros4() { + uint8_t d[4] = {}; + QVERIFY(inferTypes(d, 4).isEmpty()); + } + void allZeros2() { + uint8_t d[2] = {}; + QVERIFY(inferTypes(d, 2).isEmpty()); + } + + // ── Hex64: float pair ── + // {21.0488f, 547.3f} — two clear floats with fractional parts; + // whole-width Double/Ptr64 score poorly → Float×2 dominates + void hex64_floatPair() { + float a = 21.0488f, b = 547.3f; + uint8_t d[8]; + std::memcpy(d, &a, 4); + std::memcpy(d + 4, &b, 4); + auto r = inferTypes(d, 8); + QVERIFY(!r.isEmpty()); + auto& top = r[0]; + QCOMPARE(top.kinds.size(), 2); + QCOMPARE(top.kinds[0], NodeKind::Float); + QVERIFY(top.strength >= 3); // strong + } + + // ── Hex64: int32 pair ── + // {42, 99} — two small integers + void hex64_intPair() { + int32_t a = 42, b = 99; + uint8_t d[8]; + std::memcpy(d, &a, 4); + std::memcpy(d + 4, &b, 4); + auto r = inferTypes(d, 8); + QVERIFY(!r.isEmpty()); + auto& top = r[0]; + QVERIFY(top.kinds.size() == 2); + QVERIFY(top.kinds[0] == NodeKind::Int32 || top.kinds[0] == NodeKind::UInt32); + } + + // ── Hex64: UTF-8 string ── + void hex64_utf8() { + uint8_t d[8] = {'I', 'C', 'h', 'o', 'o', 's', 'e', 'Y'}; + auto r = inferTypes(d, 8); + QVERIFY(!r.isEmpty()); + // Top should be UTF8 (strong) + bool foundUtf8 = false; + for (const auto& s : r) { + if (s.kinds.size() == 1 && s.kinds[0] == NodeKind::UTF8) { + foundUtf8 = true; + QVERIFY(s.strength >= 3); // strong + } + } + QVERIFY(foundUtf8); + } + + // ── Hex64: pointer-like value ── + void hex64_pointer() { + // 0x00007FF6A0B01000 — typical Windows user-mode address + uint8_t d[8] = {0x00, 0x10, 0xB0, 0xA0, 0xF6, 0x7F, 0x00, 0x00}; + auto r = inferTypes(d, 8); + QVERIFY(!r.isEmpty()); + bool foundPtr = false; + for (const auto& s : r) + if (s.kinds.size() == 1 && s.kinds[0] == NodeKind::Pointer64) + foundPtr = true; + QVERIFY(foundPtr); + } + + // ── Hex32: clear float ── + void hex32_float() { + // 21.0488f = 0x41A86600 + uint8_t d[4] = {0x00, 0x66, 0xA8, 0x41}; + auto r = inferTypes(d, 4); + QVERIFY(!r.isEmpty()); + QCOMPARE(r[0].kinds.size(), 1); + QCOMPARE(r[0].kinds[0], NodeKind::Float); + QVERIFY(r[0].strength >= 2); + } + + // ── Hex32: small integer with monotonic history ── + void hex32_int_monotonic() { + // Value: 0x0000BFFC = 49148 (signed: 49148) + uint8_t d[4] = {0xFC, 0xBF, 0x00, 0x00}; + InferHints h; + h.monotonic = true; + h.sampleCount = 10; + uint8_t minB[4] = {0x10, 0x00, 0x00, 0x00}; // 16 + uint8_t maxB[4] = {0xFC, 0xBF, 0x00, 0x00}; // 49148 + h.minObserved = minB; + h.maxObserved = maxB; + auto r = inferTypes(d, 4, h); + QVERIFY(!r.isEmpty()); + QVERIFY(r[0].kinds[0] == NodeKind::Int32 || r[0].kinds[0] == NodeKind::UInt32); + QVERIFY(r[0].strength >= 2); + } + + // ── Hex16: small unsigned ── + void hex16_uint() { + uint8_t d[2] = {0x5F, 0x00}; // 95 + auto r = inferTypes(d, 2); + QVERIFY(!r.isEmpty()); + QVERIFY(r[0].kinds[0] == NodeKind::Int16 || r[0].kinds[0] == NodeKind::UInt16); + } + + // ── Hex8: bool-like ── + void hex8_bool() { + uint8_t d[1] = {1}; + auto r = inferTypes(d, 1); + QVERIFY(!r.isEmpty()); + bool foundBool = false; + for (const auto& s : r) + if (s.kinds.size() == 1 && s.kinds[0] == NodeKind::Bool) + foundBool = true; + QVERIFY(foundBool); + } + + // ── formatHint ── + void formatHint_single() { + TypeSuggestion s; + s.kinds = {NodeKind::Float}; + QCOMPARE(formatHint(s), QStringLiteral("float")); + } + void formatHint_split() { + TypeSuggestion s; + s.kinds = {NodeKind::Float, NodeKind::Float}; + QString h = formatHint(s); + QVERIFY(h.contains("float")); + QVERIFY(h.contains("2")); + } + + // ── Denormal rejection ── + void denormalRejected() { + // Denormal float: exp=0, mantissa non-zero → 0x00000001 + uint8_t d[4] = {0x01, 0x00, 0x00, 0x00}; + auto r = inferTypes(d, 4); + // Should NOT suggest Float as top pick + if (!r.isEmpty() && r[0].kinds.size() == 1) + QVERIFY(r[0].kinds[0] != NodeKind::Float); + } + + // ── Benchmark: single call ── + void bench_singleCall() { + uint8_t d[8] = {0x00, 0x00, 0x80, 0x3F, 0xCD, 0xCC, 0x4C, 0x3E}; + QBENCHMARK { + inferTypes(d, 8); + } + } + + // ── Benchmark: 200-node batch (simulates one refresh) ── + void bench_batchRefresh() { + // Prepare 200 varied byte patterns + QVector> data(200); + for (int i = 0; i < 200; ++i) { + uint32_t seed = (uint32_t)(i * 7919 + 1); + for (int j = 0; j < 8; ++j) + data[i][j] = (uint8_t)((seed >> (j * 3)) ^ (i + j)); + } + QBENCHMARK { + for (int i = 0; i < 200; ++i) + inferTypes(data[i].data(), 8); + } + } +}; + +QTEST_MAIN(TestTypeInfer) +#include "test_typeinfer.moc"