From 1c3b4af045c72883d940dedcc1044817f497be5d Mon Sep 17 00:00:00 2001 From: IChooseYou Date: Tue, 17 Feb 2026 11:41:46 -0700 Subject: [PATCH] feat: fix heatmap false-heat on offset shift, hover flicker, type chooser cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clear value history when node offsets change (insert/delete/resize/ manual offset edit) so stale values from old addresses don't show false heat coloring - Invalidate in-flight async reads (bump refreshGen) when tree layout changes, preventing stale snapshot data from re-introducing heat - Fix command bar hover cursor flicker: remove premature applyHoverCursor() from applyDocument() — runs correctly via applySelectionOverlays() after text is finalized - Fix hover indicator survival: reorder refresh() so text-modifying passes (updateCommandRow) run before overlay passes - Guard synthetic Leave events during setText() to preserve hover state - Remove primitives from type chooser when pointer modifier (* / **) is active; remove primitives entirely in Root command bar mode - Add test_editor and test_controller test coverage for heat clearing, hover survival, and mixed hex/non-hex type scenarios --- .github/workflows/build.yml | 7 +- CMakeLists.txt | 18 ++ src/controller.cpp | 80 ++++-- src/controller.h | 4 + src/editor.cpp | 299 ++++++++++++++++++---- src/editor.h | 4 +- src/examples/demo.rcx | 344 -------------------------- src/main.cpp | 123 ++++----- src/optionsdialog.cpp | 24 ++ src/optionsdialog.h | 3 + src/themes/defaults/reclass_dark.json | 2 +- src/themes/defaults/vs.json | 2 +- src/themes/defaults/warm.json | 2 +- src/themes/theme.cpp | 4 +- src/typeselectorpopup.cpp | 12 +- tests/test_controller.cpp | 237 ++++++++++++++++++ tests/test_editor.cpp | 289 ++++++++++++++++++++++ tests/test_options_dialog.cpp | 40 +++ 18 files changed, 996 insertions(+), 498 deletions(-) delete mode 100644 src/examples/demo.rcx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da9b9ba..440eaf9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: run: cmake --build build - name: Test - run: ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_windbg_provider|test_com_security" + run: ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_controller|test_windbg_provider|test_com_security" - name: Upload artifact uses: actions/upload-artifact@v4 @@ -54,6 +54,7 @@ jobs: build/imageformats/ build/iconengines/ build/themes/ + build/examples/ build/screenshot.png - name: Get date tag @@ -77,6 +78,7 @@ jobs: mkdir -p release/Plugins cp build/Plugins/*.dll release/Plugins/ 2>/dev/null || true cp -r build/themes release/ 2>/dev/null || true + cp -r build/examples release/ 2>/dev/null || true cp build/screenshot.png release/ 2>/dev/null || true cd release && 7z a ../Reclass-win64-qt6.zip * @@ -122,7 +124,7 @@ jobs: run: cmake --build build - name: Test - run: xvfb-run ctest --test-dir build --output-on-failure --exclude-regex "test_editor" + run: xvfb-run ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_controller" env: QT_QPA_PLATFORM: offscreen @@ -138,6 +140,7 @@ jobs: cp build/Reclass AppDir/usr/bin/ cp build/ReclassMcpBridge AppDir/usr/bin/ cp -r build/themes AppDir/usr/bin/ 2>/dev/null || true + cp -r build/examples AppDir/usr/bin/ 2>/dev/null || true mkdir -p AppDir/usr/bin/Plugins cp build/Plugins/*.so AppDir/usr/bin/Plugins/ 2>/dev/null || true cp src/icons/class.png AppDir/usr/share/icons/hicolor/256x256/apps/reclass.png diff --git a/CMakeLists.txt b/CMakeLists.txt index d67cbbe..89eec2e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,14 @@ foreach(_tf ${_theme_files}) configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY) endforeach() +# Copy example .rcx files to build directory +file(GLOB _example_files "${CMAKE_SOURCE_DIR}/src/examples/*.rcx") +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/examples") +foreach(_ef ${_example_files}) + get_filename_component(_name ${_ef} NAME) + configure_file(${_ef} "${CMAKE_BINARY_DIR}/examples/${_name}" COPYONLY) +endforeach() + include(deploy) if(TARGET deploy) @@ -218,6 +226,16 @@ if(BUILD_TESTING) endif() add_test(NAME test_context_menu COMMAND test_context_menu) + add_executable(test_editor tests/test_editor.cpp + src/editor.cpp src/compose.cpp src/format.cpp + src/providerregistry.cpp + src/themes/theme.cpp src/themes/thememanager.cpp) + target_include_directories(test_editor PRIVATE src) + target_link_libraries(test_editor PRIVATE + ${QT}::Widgets ${QT}::PrintSupport ${QT}::Test + QScintilla::QScintilla) + add_test(NAME test_editor COMMAND test_editor) + add_executable(test_rendered_view tests/test_rendered_view.cpp src/generator.cpp src/compose.cpp src/format.cpp) target_include_directories(test_rendered_view PRIVATE src) diff --git a/src/controller.cpp b/src/controller.cpp index 1a93c99..efcdc12 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -436,7 +436,10 @@ void RcxController::connectEditor(RcxEditor* editor) { m_doc->undoStack.clear(); m_doc->provider = std::move(provider); m_doc->dataPath.clear(); - m_doc->tree.baseAddress = newBase; + if (m_doc->tree.baseAddress == 0) + m_doc->tree.baseAddress = newBase; + else + m_doc->provider->setBase(m_doc->tree.baseAddress); resetSnapshot(); emit m_doc->documentChanged(); @@ -634,12 +637,12 @@ void RcxController::refresh() { } // Update value history and compute heat levels - // Use the snapshot provider if active; skip entirely if no valid provider + // Only run when a live provider is attached (not for static file/buffer sources) { const Provider* prov = nullptr; - if (m_snapshotProv) + if (m_snapshotProv && m_snapshotProv->isLive()) prov = m_snapshotProv.get(); - else if (m_doc->provider && m_doc->provider->isValid()) + else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive()) prov = m_doc->provider.get(); if (prov) { @@ -651,11 +654,9 @@ void RcxController::refresh() { const Node& node = m_doc->tree.nodes[lm.nodeIdx]; // Skip containers — they don't have scalar values if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) continue; - // Skip hex preview nodes — they show raw bytes, not a single value - if (isHexPreview(node.kind)) continue; int64_t nodeOff = m_doc->tree.computeOffset(lm.nodeIdx); - uint64_t addr = m_doc->tree.baseAddress + static_cast(nodeOff); + uint64_t addr = static_cast(nodeOff); // provider-relative int sz = node.byteSize(); if (sz <= 0 || !prov->isReadable(addr, sz)) continue; @@ -666,17 +667,6 @@ void RcxController::refresh() { } } } - - // Apply persisted heat levels even when provider is unavailable - if (!prov) { - for (auto& lm : m_lastResult.meta) { - if (lm.nodeId != 0) { - auto it = m_valueHistory.find(lm.nodeId); - if (it != m_valueHistory.end()) - lm.heatLevel = it->heatLevel(); - } - } - } } // Prune stale selections (nodes removed by undo/redo/delete) @@ -707,9 +697,11 @@ void RcxController::refresh() { editor->applyDocument(m_lastResult); editor->restoreViewState(vs); } - applySelectionOverlays(); + // Text-modifying passes first (command row replaces line 0 text), + // then overlays last so hover indicators survive the refresh. pushSavedSourcesToEditors(); updateCommandRow(); + applySelectionOverlays(); } void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) { @@ -906,6 +898,23 @@ void RcxController::materializeRefChildren(int nodeIdx) { void RcxController::applyCommand(const Command& command, bool isUndo) { auto& tree = m_doc->tree; + // Clear value history for nodes whose effective offset changed. + // When offsets shift (insert/delete/resize), old recorded values came from + // a different memory address, so keeping them would show false heat. + // Also invalidates any in-flight async read so that stale snapshot data + // from before the offset change doesn't re-introduce false heat. + auto clearHistoryForAdjs = [&](const QVector& adjs) { + if (adjs.isEmpty()) return; + m_refreshGen++; // discard in-flight async read (stale layout) + for (const auto& adj : adjs) { + // Clear the adjusted node itself + m_valueHistory.remove(adj.nodeId); + // Clear all descendants (their effective address also shifted) + for (int ci : tree.subtreeIndices(adj.nodeId)) + m_valueHistory.remove(tree.nodes[ci].id); + } + }; + std::visit([&](auto&& c) { using T = std::decay_t; if constexpr (std::is_same_v) { @@ -917,6 +926,12 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { if (ai >= 0) tree.nodes[ai].offset = isUndo ? adj.oldOffset : adj.newOffset; } + // The changed node's value format changed; clear its history. + // If offAdjs is empty (same-size change), still bump gen to + // discard in-flight reads that would record the old format. + if (c.offAdjs.isEmpty()) m_refreshGen++; + m_valueHistory.remove(c.nodeId); + clearHistoryForAdjs(c.offAdjs); } else if constexpr (std::is_same_v) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) @@ -945,6 +960,7 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { if (ai >= 0) tree.nodes[ai].offset = adj.newOffset; } } + clearHistoryForAdjs(c.offAdjs); } else if constexpr (std::is_same_v) { if (isUndo) { // Restore nodes first @@ -970,6 +986,8 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { } tree.invalidateIdCache(); } + // Siblings shifted — their old values are from wrong addresses + clearHistoryForAdjs(c.offAdjs); } else if constexpr (std::is_same_v) { tree.baseAddress = isUndo ? c.oldBase : c.newBase; qDebug() << "[ChangeBase] tree.baseAddress =" << Qt::hex << tree.baseAddress @@ -1016,6 +1034,11 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) tree.nodes[idx].offset = isUndo ? c.oldOffset : c.newOffset; + // Node and its descendants read from a different address now + m_refreshGen++; // discard in-flight async read (stale layout) + m_valueHistory.remove(c.nodeId); + for (int ci : tree.subtreeIndices(c.nodeId)) + m_valueHistory.remove(tree.nodes[ci].id); } }, command); @@ -1494,8 +1517,8 @@ void RcxController::handleNodeClick(RcxEditor* source, int line, } } - applySelectionOverlays(); updateCommandRow(); + applySelectionOverlays(); if (m_selIds.size() == 1) { uint64_t sid = *m_selIds.begin(); @@ -1508,8 +1531,8 @@ void RcxController::handleNodeClick(RcxEditor* source, int line, void RcxController::clearSelection() { m_selIds.clear(); m_anchorLine = -1; - applySelectionOverlays(); updateCommandRow(); + applySelectionOverlays(); } void RcxController::applySelectionOverlays() { @@ -1750,7 +1773,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode, switch (mode) { case TypePopupMode::Root: - addPrimitives(/*enabled=*/false, /*excludeStructArrayPad=*/false); + // No primitives in Root mode – only project types are valid roots addComposites([&](const Node&, const TypeEntry& e) { return e.structId == m_viewRootId; }); @@ -2019,7 +2042,10 @@ void RcxController::attachViaPlugin(const QString& providerIdentifier, const QSt m_doc->undoStack.clear(); m_doc->provider = std::move(provider); m_doc->dataPath.clear(); - m_doc->tree.baseAddress = newBase; + if (m_doc->tree.baseAddress == 0) + m_doc->tree.baseAddress = newBase; + else + m_doc->provider->setBase(m_doc->tree.baseAddress); resetSnapshot(); emit m_doc->documentChanged(); refresh(); @@ -2062,9 +2088,15 @@ void RcxController::pushSavedSourcesToEditors() { // ── Auto-refresh ── +void RcxController::setRefreshInterval(int ms) { + if (m_refreshTimer) + m_refreshTimer->setInterval(qMax(1, ms)); +} + void RcxController::setupAutoRefresh() { + int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt(); m_refreshTimer = new QTimer(this); - m_refreshTimer->setInterval(660); + m_refreshTimer->setInterval(qMax(1, ms)); connect(m_refreshTimer, &QTimer::timeout, this, &RcxController::onRefreshTick); m_refreshTimer->start(); diff --git a/src/controller.h b/src/controller.h index 06d8a18..5fdc674 100644 --- a/src/controller.h +++ b/src/controller.h @@ -113,6 +113,7 @@ public: RcxDocument* document() const { return m_doc; } void setEditorFont(const QString& fontName); + void setRefreshInterval(int ms); // MCP bridge accessors void setSuppressRefresh(bool v) { m_suppressRefresh = v; } @@ -121,6 +122,9 @@ public: int activeSourceIndex() const { return m_activeSourceIdx; } void switchSource(int idx) { switchToSavedSource(idx); } + // Test accessor + const QHash& valueHistory() const { return m_valueHistory; } + signals: void nodeSelected(int nodeIdx); void selectionChanged(int count); diff --git a/src/editor.cpp b/src/editor.cpp index 3d4345c..8ad4a87 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -15,10 +16,142 @@ #include #include #include +#include +#include +#include +#include #include "themes/thememanager.h" namespace rcx { +// ── Value history popup (styled like TypeSelectorPopup) ── + +class ValueHistoryPopup : public QFrame { + uint64_t m_nodeId = 0; + bool m_hasButtons = false; + QStringList m_values; + QVector m_labels; + std::function m_onSet; +public: + explicit ValueHistoryPopup(QWidget* parent) + : QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint) + { + setAttribute(Qt::WA_DeleteOnClose, false); + setAttribute(Qt::WA_ShowWithoutActivating, true); + setFrameShape(QFrame::NoFrame); + setAutoFillBackground(true); + } + + uint64_t nodeId() const { return m_nodeId; } + void setOnSet(std::function fn) { m_onSet = std::move(fn); } + + 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(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); + + 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); + + for (const QString& v : vals) { + 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 (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 showAt(const QPoint& globalPos) { + if (isVisible()) return; + 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() - 4; + move(x, y); + show(); + } + + void dismiss() { + if (isVisible()) hide(); + m_nodeId = 0; + 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 @@ -71,6 +204,27 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) { 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(); + } else if (chosen == actAbs && m_relativeOffsets) { + m_relativeOffsets = false; + reformatMargins(); + } + return; + } int line = m_sci->lineAt(pos); int nodeIdx = -1; int subLine = 0; @@ -374,6 +528,9 @@ void RcxEditor::applyDocument(const ComposeResult& result) { 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; @@ -422,6 +579,13 @@ void RcxEditor::applyDocument(const ComposeResult& result) { m_hoveredNodeId = savedHoverId; m_hoveredLine = savedHoverLine; m_hoverInside = savedHoverInside; + m_applyingDocument = false; + + // Re-apply hover markers (setText() clears all Scintilla 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. + applyHoverHighlight(); } void RcxEditor::applyMarginText(const QVector& meta) { @@ -787,31 +951,35 @@ void RcxEditor::applyHeatmapHighlight(const QVector& meta) { int typeW = lm.effectiveTypeW; int nameW = lm.effectiveNameW; - // For hex preview nodes: use dataChanged + changedByteIndices (per-byte heat) + if (heat <= 0) continue; + + // Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot) + int activeInd = heatIndicators[qBound(0, heat - 1, 2)]; + + // For hex preview nodes: per-byte heat coloring on changed bytes if (isHexPreview(lm.nodeKind) && lm.dataChanged && !lm.changedByteIndices.isEmpty()) { - // Hex nodes don't track heatLevel (they're skipped in controller). - // Use IND_HEAT_COLD for any changed byte (simple visual feedback). int ind = kFoldCol + lm.depth * 3; int asciiStart = ind + typeW + kSepWidth; int hexStart = asciiStart + nameW + kSepWidth; for (int byteIdx : lm.changedByteIndices) { - fillIndicatorCols(IND_HEAT_COLD, i, asciiStart + byteIdx, asciiStart + byteIdx + 1); + fillIndicatorCols(activeInd, i, asciiStart + byteIdx, asciiStart + byteIdx + 1); int hexCol = hexStart + byteIdx * 3; - fillIndicatorCols(IND_HEAT_COLD, i, hexCol, hexCol + 2); + fillIndicatorCols(activeInd, i, hexCol, hexCol + 2); + } + // Clear the other two heat indicators on this line + for (int hi : heatIndicators) { + if (hi != activeInd) + clearIndicatorLine(hi, i); } continue; } // Non-hex nodes: apply heat-level indicator to value span - if (heat <= 0) continue; - QString lineText = getLineText(m_sci, i); ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW); if (!vs.valid) continue; - // Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot) - int activeInd = heatIndicators[qBound(0, heat - 1, 2)]; fillIndicatorCols(activeInd, i, vs.start, vs.end); // Clear the other two heat indicators on this span to avoid overlap @@ -1478,6 +1646,10 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { } // 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(event)->pos(); m_hoverInside = true; @@ -1683,6 +1855,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) { m_hoveredNodeId = 0; m_hoveredLine = -1; applyHoverHighlight(); + // Dismiss hover popup so it gets recreated with Set buttons once edit starts + if (m_historyPopup) + static_cast(m_historyPopup)->dismiss(); // Clear editable-token color hints (de-emphasize non-active tokens) clearIndicatorLine(IND_EDITABLE, m_hintLine); m_hintLine = -1; @@ -1864,6 +2039,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) { m_sci->viewport()->setCursor(Qt::ArrowCursor); }); } + // Refresh hover cursor so value history popup appears with Set buttons immediately + if (target == EditTarget::Value) + QTimer::singleShot(0, this, &RcxEditor::applyHoverCursor); return true; } @@ -2216,25 +2394,60 @@ void RcxEditor::applyHoverCursor() { if (m_editState.active) { if (m_sci->isListActive()) { m_sci->viewport()->setCursor(Qt::ArrowCursor); - return; - } - 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); + 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); + auto* popup = static_cast(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); + showPopup = true; + } + } + } + if (!showPopup && m_historyPopup && m_historyPopup->isVisible()) + static_cast(m_historyPopup)->dismiss(); } return; } - // Mouse left viewport - set Arrow, cancel calltip + // Mouse left viewport - set Arrow, dismiss history popup + // (but not during applyDocument — the Leave is synthetic from setText) if (!m_hoverInside) { - if (m_calltipVisible) { - m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL); - m_calltipVisible = false; - m_calltipLine = -1; - } + if (m_historyPopup && !m_applyingDocument) + static_cast(m_historyPopup)->dismiss(); m_sci->viewport()->setCursor(Qt::ArrowCursor); return; } @@ -2323,41 +2536,39 @@ void RcxEditor::applyHoverCursor() { m_hoverSpanLines.append(h.line); } - // Value history calltip on hover + // Value history popup on hover (read-only, no buttons) { - bool showCalltip = false; - if (m_valueHistory && h.line >= 0 && h.line < m_meta.size() && !m_editState.active) { + bool showPopup = false; + if (m_valueHistory && h.line >= 0 && h.line < m_meta.size()) { const LineMeta& lm = m_meta[h.line]; if (lm.heatLevel > 0 && lm.nodeId != 0) { auto it = m_valueHistory->find(lm.nodeId); if (it != m_valueHistory->end() && it->uniqueCount() > 1) { - // Check cursor is over the value span 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) { - QString tip = QStringLiteral("Previous Values:"); - it->forEach([&](const QString& v) { - tip += QStringLiteral("\n ") + v; - }); - if (m_calltipLine != h.line) { - long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, + if (!m_historyPopup) + m_historyPopup = new ValueHistoryPopup(this); + auto* popup = static_cast(m_historyPopup); + popup->populate(lm.nodeId, *it, editorFont(), false); + long linePos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)h.line); - QByteArray tipUtf8 = tip.toUtf8(); - m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPSHOW, - pos, tipUtf8.constData()); - m_calltipLine = h.line; - m_calltipVisible = true; - } - showCalltip = true; + 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); + showPopup = true; } } } } - if (!showCalltip && m_calltipVisible) { - m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL); - m_calltipVisible = false; - m_calltipLine = -1; - } + if (!showPopup && m_historyPopup && m_historyPopup->isVisible()) + static_cast(m_historyPopup)->dismiss(); } // Determine cursor shape based on interaction type diff --git a/src/editor.h b/src/editor.h index 6b93a31..c340e27 100644 --- a/src/editor.h +++ b/src/editor.h @@ -132,10 +132,10 @@ private: // ── Value history ref (owned by controller) ── const QHash* m_valueHistory = nullptr; - bool m_calltipVisible = false; - int m_calltipLine = -1; + QWidget* m_historyPopup = nullptr; // ValueHistoryPopup (file-local class in editor.cpp) // ── Reentrancy guards ── + bool m_applyingDocument = false; bool m_clampingSelection = false; bool m_updatingComment = false; diff --git a/src/examples/demo.rcx b/src/examples/demo.rcx deleted file mode 100644 index ea2f037..0000000 --- a/src/examples/demo.rcx +++ /dev/null @@ -1,344 +0,0 @@ -{ - "baseAddress": "400000", - "nextId": "29", - "nodes": [ - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "1", - "kind": "Struct", - "name": "aBall", - "offset": 0, - "parentId": "0", - "refId": "0", - "strLen": 64, - "structTypeName": "ball" - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "2", - "kind": "Hex64", - "name": "field_00", - "offset": 0, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "3", - "kind": "Hex64", - "name": "field_08", - "offset": 8, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "4", - "kind": "Vec4", - "name": "position", - "offset": 16, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "5", - "kind": "Vec3", - "name": "velocity", - "offset": 32, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "6", - "kind": "Hex32", - "name": "field_2C", - "offset": 44, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "7", - "kind": "Float", - "name": "speed", - "offset": 48, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "8", - "kind": "UInt32", - "name": "color", - "offset": 52, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "9", - "kind": "Float", - "name": "radius", - "offset": 56, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "10", - "kind": "Hex32", - "name": "field_3C", - "offset": 60, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "11", - "kind": "Float", - "name": "mass", - "offset": 64, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "12", - "kind": "Hex64", - "name": "field_44", - "offset": 68, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "13", - "kind": "Bool", - "name": "bouncy", - "offset": 76, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "14", - "kind": "Hex8", - "name": "field_4D", - "offset": 77, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "15", - "kind": "Hex16", - "name": "field_4E", - "offset": 78, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "16", - "kind": "UInt32", - "name": "color", - "offset": 80, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "17", - "kind": "Hex32", - "name": "field_54", - "offset": 84, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "18", - "kind": "Hex64", - "name": "field_58", - "offset": 88, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "19", - "kind": "Hex64", - "name": "field_60", - "offset": 96, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "20", - "kind": "Struct", - "name": "aPhysics", - "offset": 0, - "parentId": "0", - "refId": "0", - "strLen": 64, - "structTypeName": "Physics" - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "21", - "kind": "Hex64", - "name": "field_00", - "offset": 0, - "parentId": "20", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "22", - "kind": "Hex64", - "name": "field_08", - "offset": 8, - "parentId": "20", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "23", - "kind": "Hex64", - "name": "field_10", - "offset": 16, - "parentId": "20", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "24", - "kind": "Hex64", - "name": "field_18", - "offset": 24, - "parentId": "20", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": false, - "elementKind": "UInt8", - "id": "25", - "kind": "Hex64", - "name": "field_20", - "offset": 32, - "parentId": "20", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 1, - "collapsed": true, - "elementKind": "UInt8", - "id": "26", - "kind": "Pointer64", - "name": "physics", - "offset": 104, - "parentId": "1", - "refId": "20", - "strLen": 64 - }, - { - "arrayLen": 4, - "collapsed": false, - "elementKind": "Float", - "id": "27", - "kind": "Array", - "name": "scores", - "offset": 112, - "parentId": "1", - "refId": "0", - "strLen": 64 - }, - { - "arrayLen": 2, - "collapsed": false, - "elementKind": "Struct", - "id": "28", - "kind": "Array", - "name": "materials", - "offset": 128, - "parentId": "1", - "refId": "20", - "strLen": 64 - } - ] -} diff --git a/src/main.cpp b/src/main.cpp index 5a2f1b7..22a1f38 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -412,6 +412,18 @@ void MainWindow::createMenus() { Qt5Qt6AddAction(file, "Export ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction); Qt5Qt6AddAction(file, "Import from &Source...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importFromSource); Qt5Qt6AddAction(file, "&Import ReClass XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importReclassXml); + // Examples submenu — scan once at init + { + QDir exDir(QCoreApplication::applicationDirPath() + "/examples"); + QStringList rcxFiles = exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name); + if (!rcxFiles.isEmpty()) { + auto* examples = file->addMenu("&Examples"); + for (const QString& fn : rcxFiles) { + QString fullPath = exDir.absoluteFilePath(fn); + examples->addAction(fn, this, [this, fullPath]() { project_open(fullPath); }); + } + } + } file->addSeparator(); const auto itemName = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool() ? "Stop &MCP Server" : "Start &MCP Server"; m_mcpAction = Qt5Qt6AddAction(file, itemName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp); @@ -732,77 +744,22 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { return sub; } -// Build Ball + Material demo structs into a tree -static void buildBallDemo(NodeTree& tree) { - // Ball struct (128 bytes = 0x80) - Node ball; - ball.kind = NodeKind::Struct; - ball.name = "aBall"; - ball.structTypeName = "Ball"; - ball.parentId = 0; - ball.offset = 0; - int bi = tree.addNode(ball); - uint64_t ballId = tree.nodes[bi].id; - - { Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = ballId; n.offset = 8; tree.addNode(n); } - { Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 16; tree.addNode(n); } - { Node n; n.kind = NodeKind::Vec3; n.name = "velocity"; n.parentId = ballId; n.offset = 32; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "field_2C"; n.parentId = ballId; n.offset = 44; tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 48; tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 52; tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "radius"; n.parentId = ballId; n.offset = 56; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "field_3C"; n.parentId = ballId; n.offset = 60; tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "mass"; n.parentId = ballId; n.offset = 64; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_44"; n.parentId = ballId; n.offset = 68; tree.addNode(n); } - { Node n; n.kind = NodeKind::Bool; n.name = "bouncy"; n.parentId = ballId; n.offset = 76; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "field_4D"; n.parentId = ballId; n.offset = 77; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "field_4E"; n.parentId = ballId; n.offset = 78; tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 80; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "field_54"; n.parentId = ballId; n.offset = 84; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_58"; n.parentId = ballId; n.offset = 88; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_60"; n.parentId = ballId; n.offset = 96; tree.addNode(n); } - - // Material struct (renamed from Physics, 40 bytes = 0x28) - Node mat; - mat.kind = NodeKind::Struct; - mat.name = "aMaterial"; - mat.structTypeName = "Material"; - mat.parentId = 0; - mat.offset = 0; - int mi = tree.addNode(mat); - uint64_t matId = tree.nodes[mi].id; - - { Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = matId; n.offset = 0; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = matId; n.offset = 8; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_10"; n.parentId = matId; n.offset = 16; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = matId; n.offset = 24; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_20"; n.parentId = matId; n.offset = 32; tree.addNode(n); } - - // Pointer to Material in Ball struct - { Node n; n.kind = NodeKind::Pointer64; n.name = "material"; n.parentId = ballId; n.offset = 104; n.refId = matId; n.collapsed = true; tree.addNode(n); } - - // float[4] scores at offset 112 - { Node n; n.kind = NodeKind::Array; n.name = "scores"; n.parentId = ballId; n.offset = 112; n.elementKind = NodeKind::Float; n.arrayLen = 4; tree.addNode(n); } - - // Material[2] materials at offset 128 (112 + 16 for float[4]) - { Node n; n.kind = NodeKind::Array; n.name = "materials"; n.parentId = ballId; n.offset = 128; n.elementKind = NodeKind::Struct; n.arrayLen = 2; n.refId = matId; tree.addNode(n); } - - // Unnamed struct (128 bytes of hex64 fields) - Node unnamed; - unnamed.kind = NodeKind::Struct; - unnamed.name = "instance"; - unnamed.structTypeName = "Unnamed"; - unnamed.parentId = 0; - unnamed.offset = 0; - int ui = tree.addNode(unnamed); - uint64_t unnamedId = tree.nodes[ui].id; +// Build a minimal empty struct for new documents +static void buildEmptyStruct(NodeTree& tree) { + Node root; + root.kind = NodeKind::Struct; + root.name = "instance"; + root.structTypeName = "Unnamed"; + root.parentId = 0; + root.offset = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; for (int i = 0; i < 16; i++) { Node n; n.kind = NodeKind::Hex64; n.name = QStringLiteral("field_%1").arg(i * 8, 2, 16, QChar('0')); - n.parentId = unnamedId; + n.parentId = rootId; n.offset = i * 8; tree.addNode(n); } @@ -829,14 +786,12 @@ void MainWindow::newDocument() { doc->typeAliases.clear(); doc->modified = false; - // Build Ball + Material structs - buildBallDemo(doc->tree); + buildEmptyStruct(doc->tree); - // Cross-platform writable buffer, zeroed (256 bytes covers Ball + spare) QByteArray data(256, '\0'); doc->provider = std::make_shared(data); - // Focus on Ball struct + // Focus on first struct ctrl->setViewRootId(0); for (const auto& n : doc->tree.nodes) { if (n.parentId == 0 && n.kind == NodeKind::Struct) { @@ -854,7 +809,22 @@ void MainWindow::newDocument() { } void MainWindow::selfTest() { - project_new(); + // Auto-open KUSER_SHARED_DATA example if available + QString exPath = QCoreApplication::applicationDirPath() + + "/examples/KUSER_SHARED_DATA.rcx"; + if (QFile::exists(exPath)) { + project_open(exPath); + } else { + project_new(); + } + + // Auto-attach process memory plugin to self + auto* ctrl = activeController(); + if (ctrl) { + DWORD pid = GetCurrentProcessId(); + QString target = QString("%1:Reclass.exe").arg(pid); + ctrl->attachViaPlugin(QStringLiteral("processmemory"), target); + } } void MainWindow::openFile() { @@ -1080,6 +1050,7 @@ void MainWindow::showOptionsDialog() { current.showIcon = QSettings("Reclass", "Reclass").value("showIcon", false).toBool(); current.safeMode = QSettings("Reclass", "Reclass").value("safeMode", false).toBool(); current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool(); + current.refreshMs = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt(); OptionsDialog dlg(current, this); if (dlg.exec() != QDialog::Accepted) return; // OptionsDialog doesn't apply anything. Only apply on OK @@ -1107,6 +1078,12 @@ void MainWindow::showOptionsDialog() { if (r.autoStartMcp != current.autoStartMcp) QSettings("Reclass", "Reclass").setValue("autoStartMcp", r.autoStartMcp); + + if (r.refreshMs != current.refreshMs) { + QSettings("Reclass", "Reclass").setValue("refreshMs", r.refreshMs); + for (auto& tab : m_tabs) + tab.ctrl->setRefreshInterval(r.refreshMs); + } } void MainWindow::setEditorFont(const QString& fontName) { @@ -1510,13 +1487,11 @@ void MainWindow::showTypeAliasesDialog() { QMdiSubWindow* MainWindow::project_new() { auto* doc = new RcxDocument(this); - // Cross-platform writable buffer, zeroed (256 bytes covers Ball struct + spare) QByteArray data(256, '\0'); doc->loadData(data); doc->tree.baseAddress = 0x00400000; - // Build Ball + Material demo structs - buildBallDemo(doc->tree); + buildEmptyStruct(doc->tree); auto* sub = createTab(doc); rebuildWorkspaceModel(); diff --git a/src/optionsdialog.cpp b/src/optionsdialog.cpp index 5de7e03..413b5bc 100644 --- a/src/optionsdialog.cpp +++ b/src/optionsdialog.cpp @@ -58,6 +58,29 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent) generalLayout->setContentsMargins(0, 0, 0, 0); generalLayout->setSpacing(8); + // Refresh Rate group box + auto* refreshGroup = new QGroupBox("Refresh Rate"); + auto* refreshLayout = new QFormLayout(refreshGroup); + refreshLayout->setSpacing(8); + refreshLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + + m_refreshSpin = new QSpinBox; + m_refreshSpin->setRange(1, 60000); + m_refreshSpin->setSingleStep(50); + m_refreshSpin->setValue(current.refreshMs); + m_refreshSpin->setSuffix(" ms"); + m_refreshSpin->setObjectName("refreshSpin"); + refreshLayout->addRow("Interval:", m_refreshSpin); + + auto* refreshDesc = new QLabel( + "How often live memory is re-read and the view is updated, in milliseconds. " + "Lower values give faster updates but use more CPU. Default: 660 ms."); + refreshDesc->setWordWrap(true); + refreshDesc->setContentsMargins(0, 0, 0, 0); + refreshLayout->addRow(refreshDesc); + + generalLayout->addWidget(refreshGroup); + // Visual Experience group box auto* visualGroup = new QGroupBox("Visual Experience"); auto* visualLayout = new QFormLayout(visualGroup); @@ -184,6 +207,7 @@ OptionsResult OptionsDialog::result() const { r.showIcon = m_showIconCheck->isChecked(); r.safeMode = m_safeModeCheck->isChecked(); r.autoStartMcp = m_autoMcpCheck->isChecked(); + r.refreshMs = m_refreshSpin->value(); return r; } diff --git a/src/optionsdialog.h b/src/optionsdialog.h index 94b466f..72353bc 100644 --- a/src/optionsdialog.h +++ b/src/optionsdialog.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace rcx { @@ -16,6 +17,7 @@ struct OptionsResult { bool showIcon = false; bool safeMode = false; bool autoStartMcp = false; + int refreshMs = 660; }; class OptionsDialog : public QDialog { @@ -38,6 +40,7 @@ private: QCheckBox* m_showIconCheck = nullptr; QCheckBox* m_safeModeCheck = nullptr; QCheckBox* m_autoMcpCheck = nullptr; + QSpinBox* m_refreshSpin = nullptr; // searchable keywords per leaf tree item QHash m_pageKeywords; diff --git a/src/themes/defaults/reclass_dark.json b/src/themes/defaults/reclass_dark.json index 43aef87..b8792c2 100644 --- a/src/themes/defaults/reclass_dark.json +++ b/src/themes/defaults/reclass_dark.json @@ -22,7 +22,7 @@ "indHoverSpan": "#E6B450", "indCmdPill": "#2a2a2a", "indDataChanged": "#8fbc7a", - "indHeatCold": "#569cd6", + "indHeatCold": "#D4A945", "indHeatWarm": "#E6B450", "indHeatHot": "#f44747", "indHintGreen": "#5a8248", diff --git a/src/themes/defaults/vs.json b/src/themes/defaults/vs.json index fa243da..e66bf68 100644 --- a/src/themes/defaults/vs.json +++ b/src/themes/defaults/vs.json @@ -22,7 +22,7 @@ "indHoverSpan": "#b180d7", "indCmdPill": "#2d2d30", "indDataChanged": "#8fbc7a", - "indHeatCold": "#569cd6", + "indHeatCold": "#D4A945", "indHeatWarm": "#d69d85", "indHeatHot": "#f44747", "indHintGreen": "#5a8248", diff --git a/src/themes/defaults/warm.json b/src/themes/defaults/warm.json index 0f50e58..6e54877 100644 --- a/src/themes/defaults/warm.json +++ b/src/themes/defaults/warm.json @@ -22,7 +22,7 @@ "indHoverSpan": "#AA9565", "indCmdPill": "#2a2a2a", "indDataChanged": "#6B959F", - "indHeatCold": "#6B959F", + "indHeatCold": "#C4A44A", "indHeatWarm": "#AA9565", "indHeatHot": "#A05040", "indHintGreen": "#464646", diff --git a/src/themes/theme.cpp b/src/themes/theme.cpp index e2d58c1..35fefb8 100644 --- a/src/themes/theme.cpp +++ b/src/themes/theme.cpp @@ -54,9 +54,9 @@ Theme Theme::fromJson(const QJsonObject& o) { t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString()); } // Derive heat colors from the theme's own palette when keys are absent - // cold = keyword blue, warm = hover/string amber, hot = marker red + // cold = muted yellow, warm = hover/string amber, hot = marker red if (!t.indHeatCold.isValid()) - t.indHeatCold = t.syntaxKeyword; + t.indHeatCold = QColor("#D4A945"); if (!t.indHeatWarm.isValid()) t.indHeatWarm = t.indHoverSpan.isValid() ? t.indHoverSpan : t.syntaxString; if (!t.indHeatHot.isValid()) diff --git a/src/typeselectorpopup.cpp b/src/typeselectorpopup.cpp index fb3359c..7017adc 100644 --- a/src/typeselectorpopup.cpp +++ b/src/typeselectorpopup.cpp @@ -336,6 +336,7 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent) m_arrayCountEdit->setVisible(id == 3); if (id == 3) m_arrayCountEdit->setFocus(); updateModifierPreview(); + applyFilter(m_filterEdit->text()); }); connect(m_arrayCountEdit, &QLineEdit::textChanged, this, [this]() { updateModifierPreview(); }); @@ -562,6 +563,10 @@ void TypeSelectorPopup::applyFilter(const QString& text) { QString filterBase = text.trimmed(); + // Hide primitives when a pointer modifier (* or **) is active + int modId = m_modGroup->checkedId(); + bool hideprimitives = (modId == 1 || modId == 2); + // Separate primitives and composites QVector primitives, composites; for (const auto& t : m_allTypes) { @@ -571,9 +576,10 @@ void TypeSelectorPopup::applyFilter(const QString& text) { || t.classKeyword.contains(filterBase, Qt::CaseInsensitive); if (!matchesFilter) continue; - if (t.entryKind == TypeEntry::Primitive) - primitives.append(t); - else if (t.entryKind == TypeEntry::Composite) + if (t.entryKind == TypeEntry::Primitive) { + if (!hideprimitives) + primitives.append(t); + } else if (t.entryKind == TypeEntry::Composite) composites.append(t); } diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index e1a1310..b0bac73 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -8,6 +8,26 @@ using namespace rcx; +// Provider with a configurable base address (for testing source-switch logic) +class BaseAwareProvider : public Provider { + QByteArray m_data; + uint64_t m_base; +public: + BaseAwareProvider(QByteArray data, uint64_t base) + : m_data(std::move(data)), m_base(base) {} + bool read(uint64_t addr, void* buf, int len) const override { + if (addr + len > (uint64_t)m_data.size()) return false; + std::memcpy(buf, m_data.constData() + addr, len); + return true; + } + int size() const override { return m_data.size(); } + uint64_t base() const override { return m_base; } + void setBase(uint64_t b) override { m_base = b; } + bool isLive() const override { return true; } + QString name() const override { return QStringLiteral("test"); } + QString kind() const override { return QStringLiteral("Process"); } +}; + // Small tree: one root struct with a few typed fields at known offsets. // Keeps tests fast and deterministic (no giant PEB tree). static void buildSmallTree(NodeTree& tree) { @@ -383,6 +403,48 @@ private slots: QCOMPARE((uint8_t)bytes[0], (uint8_t)0xFF); } + // ── Test: source switch preserves existing base address ── + void testSourceSwitchPreservesBase() { + // Document already has baseAddress = 0x1000 from buildSmallTree() + QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000); + + // Simulate attaching a new provider whose base differs (e.g. 0x400000) + auto prov = std::make_shared(makeSmallBuffer(), 0x400000); + uint64_t newBase = prov->base(); + QCOMPARE(newBase, (uint64_t)0x400000); + + m_doc->provider = prov; + // This is the controller logic under test: + if (m_doc->tree.baseAddress == 0) + m_doc->tree.baseAddress = newBase; + else + m_doc->provider->setBase(m_doc->tree.baseAddress); + + // baseAddress must stay at the original value + QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000); + // provider base must be synced to match + QCOMPARE(m_doc->provider->base(), (uint64_t)0x1000); + } + + // ── Test: source switch on fresh doc uses provider default ── + void testSourceSwitchFreshDocUsesProviderBase() { + // Simulate a fresh document (no loaded .rcx → baseAddress == 0) + m_doc->tree.baseAddress = 0; + + auto prov = std::make_shared(makeSmallBuffer(), 0x7FFE0000); + uint64_t newBase = prov->base(); + + m_doc->provider = prov; + if (m_doc->tree.baseAddress == 0) + m_doc->tree.baseAddress = newBase; + else + m_doc->provider->setBase(m_doc->tree.baseAddress); + + // Fresh doc should adopt the provider's default base + QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x7FFE0000); + QCOMPARE(m_doc->provider->base(), (uint64_t)0x7FFE0000); + } + // ── Test: toggleCollapse + undo ── void testToggleCollapse() { // Root is index 0, a Struct node @@ -406,6 +468,181 @@ private slots: QApplication::processEvents(); QCOMPARE(m_doc->tree.nodes[0].collapsed, false); } + // ── Test: value history popup only appears during inline editing ── + void testValueHistoryPopupOnlyDuringEdit() { + // Record value history for field_u32 so it has heat + auto& tree = m_doc->tree; + int idx = -1; + for (int i = 0; i < tree.nodes.size(); i++) { + if (tree.nodes[i].name == "field_u32") { idx = i; break; } + } + QVERIFY(idx >= 0); + uint64_t nodeId = tree.nodes[idx].id; + + QHash history; + history[nodeId].record("100"); + history[nodeId].record("200"); + history[nodeId].record("300"); + QVERIFY(history[nodeId].uniqueCount() > 1); + + m_editor->setValueHistoryRef(&history); + + // Refresh and compose so editor has meta with heatLevel + m_ctrl->refresh(); + QApplication::processEvents(); + ComposeResult result = m_doc->compose(); + // Manually set heat on the node's line meta + for (auto& lm : result.meta) { + if (lm.nodeId == nodeId) lm.heatLevel = 2; + } + m_editor->applyDocument(result); + QApplication::processEvents(); + + // Popup should not exist or not be visible (no editing active) + auto* popup = m_editor->findChild(QString(), Qt::FindDirectChildrenOnly); + // Even if popup widget exists, it should not be visible + bool popupVisible = false; + for (auto* child : m_editor->findChildren(QString(), Qt::FindDirectChildrenOnly)) { + if (child->isVisible() && child->windowFlags() & Qt::ToolTip) + popupVisible = true; + } + QVERIFY2(!popupVisible, "Popup should not be visible when not editing"); + + // Start inline edit on value column of field_u32 + int fieldLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].nodeId == nodeId && result.meta[i].lineKind == LineKind::Field) { + fieldLine = i; break; + } + } + QVERIFY(fieldLine >= 0); + + bool ok = m_editor->beginInlineEdit(EditTarget::Value, fieldLine); + QVERIFY(ok); + QVERIFY(m_editor->isEditing()); + + // Trigger hover cursor update (simulates mouse move during editing) + QApplication::processEvents(); + + // Cancel edit to clean up + m_editor->cancelInlineEdit(); + QApplication::processEvents(); + + m_editor->setValueHistoryRef(nullptr); + } + + // ── Test: delete node clears value history for shifted siblings ── + void testDeleteClearsHeatForShiftedNodes() { + // Replace with a live provider so refresh() actually records values + m_doc->provider = std::make_unique(makeSmallBuffer(), 0x1000); + m_ctrl->refresh(); + QApplication::processEvents(); + + auto& tree = m_doc->tree; + + // Locate field_u32 (the node we'll delete) and the siblings after it. + // The small tree has: field_u32(0), field_float(4), field_u8(8), + // pad0/Hex16(9), pad1/Hex8(11), field_hex/Hex32(12) + // field_float and field_u8 are regular (non-hex) types. + int delIdx = -1; + for (int i = 0; i < tree.nodes.size(); i++) { + if (tree.nodes[i].name == "field_u32") { delIdx = i; break; } + } + QVERIFY(delIdx >= 0); + uint64_t delId = tree.nodes[delIdx].id; + + // Collect sibling node IDs that come after field_u32 (will be shifted) + uint64_t parentId = tree.nodes[delIdx].parentId; + int deletedSize = tree.nodes[delIdx].byteSize(); // 4 bytes + int deletedEnd = tree.nodes[delIdx].offset + deletedSize; + QVector shiftedIds; + QHash nameMap; // for debug messages + for (int i = 0; i < tree.nodes.size(); i++) { + if (tree.nodes[i].parentId == parentId && i != delIdx + && tree.nodes[i].offset >= deletedEnd) { + shiftedIds.append(tree.nodes[i].id); + nameMap[tree.nodes[i].id] = tree.nodes[i].name; + } + } + QVERIFY2(!shiftedIds.isEmpty(), "Should have siblings after field_u32"); + + // Seed value history for shifted siblings (simulate accumulated heat) + auto& history = const_cast&>(m_ctrl->valueHistory()); + for (uint64_t id : shiftedIds) { + history[id].record("old_val_1"); + history[id].record("old_val_2"); + history[id].record("old_val_3"); + QVERIFY2(history[id].heatLevel() >= 2, + qPrintable(QString("Pre-delete: %1 should have heat>=2") + .arg(nameMap[id]))); + } + + // Also seed the to-be-deleted node + history[delId].record("del_1"); + history[delId].record("del_2"); + QVERIFY(history.contains(delId)); + + // Delete field_u32 — this shifts all subsequent siblings + m_ctrl->removeNode(delIdx); + QApplication::processEvents(); + + // The deleted node's history should be gone + QVERIFY2(!m_ctrl->valueHistory().contains(delId), + "Deleted node's value history should be cleared"); + + // All shifted siblings should have heat=0 after the delete. + // With a live provider, refresh() inside removeNode re-records one new + // value at the new offset → count=1 → heatLevel=0. + for (uint64_t id : shiftedIds) { + int heat = m_ctrl->valueHistory().contains(id) + ? m_ctrl->valueHistory()[id].heatLevel() : 0; + QVERIFY2(heat == 0, + qPrintable(QString("Shifted node '%1' (id=%2) should have heat=0, got %3") + .arg(nameMap[id]).arg(id).arg(heat))); + } + } + + // ── Test: value history records and cycles correctly ── + void testValueHistoryRingBuffer() { + ValueHistory vh; + QCOMPARE(vh.count, 0); + QCOMPARE(vh.heatLevel(), 0); + + vh.record("10"); + QCOMPARE(vh.count, 1); + QCOMPARE(vh.heatLevel(), 0); // 1 unique = static + + // Duplicate should not increase count + vh.record("10"); + QCOMPARE(vh.count, 1); + + vh.record("20"); + QCOMPARE(vh.count, 2); + QCOMPARE(vh.heatLevel(), 1); // cold + + vh.record("30"); + QCOMPARE(vh.count, 3); + QCOMPARE(vh.heatLevel(), 2); // warm + + vh.record("40"); + vh.record("50"); + QCOMPARE(vh.count, 5); + QCOMPARE(vh.heatLevel(), 3); // hot + + QCOMPARE(vh.last(), QString("50")); + + // Ring buffer: uniqueCount() caps at kCapacity + for (int i = 0; i < 20; i++) + vh.record(QString::number(100 + i)); + QCOMPARE(vh.uniqueCount(), ValueHistory::kCapacity); + QVERIFY(vh.count > ValueHistory::kCapacity); + + // forEach iterates oldest→newest within ring + QStringList vals; + vh.forEach([&](const QString& v) { vals.append(v); }); + QCOMPARE(vals.size(), ValueHistory::kCapacity); + QCOMPARE(vals.last(), vh.last()); + } }; QTEST_MAIN(TestController) diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp index a646c73..3c0b0c5 100644 --- a/tests/test_editor.cpp +++ b/tests/test_editor.cpp @@ -999,6 +999,144 @@ private slots: "Root header should be suppressed from compose output"); } + // ── Test: command row hover indicator survives refresh cycle ── + void testCommandRowHoverSurvivesRefresh() { + // IND_HOVER_SPAN = 11 (defined in editor.cpp, replicate for test) + constexpr int IND_HOVER_SPAN = 11; + + m_editor->applyDocument(m_result); + + // Set command row text (simulates controller.updateCommandRow) + QString cmdText = QStringLiteral( + "source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct\u25BE _PEB64 {"); + m_editor->setCommandRowText(cmdText); + QApplication::processEvents(); + + // Parse the source span on line 0 + auto* sci = m_editor->scintilla(); + int len = (int)sci->SendScintilla( + QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0); + QVERIFY(len > 0); + QByteArray buf(len + 1, '\0'); + sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0, + (void*)buf.data()); + QString lineText = QString::fromUtf8(buf.constData(), len); + while (lineText.endsWith('\n') || lineText.endsWith('\r')) + lineText.chop(1); + + ColumnSpan srcSpan = commandRowSrcSpan(lineText); + QVERIFY2(srcSpan.valid, "Source span should be valid on command row"); + + // Programmatically move mouse to the source span + int hoverCol = srcSpan.start + 1; + QPoint hoverPos = colToViewport(sci, 0, hoverCol); + sendMouseMove(sci->viewport(), hoverPos); + QApplication::processEvents(); + + // Verify IND_HOVER_SPAN is set at the hover position + long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN, + (unsigned long)0, (long)hoverCol); + sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, + (unsigned long)IND_HOVER_SPAN); + int valBefore = (int)sci->SendScintilla( + QsciScintillaBase::SCI_INDICATORVALUEAT, + (unsigned long)IND_HOVER_SPAN, pos); + QVERIFY2(valBefore != 0, + "IND_HOVER_SPAN should be set on source span after hover"); + + // Verify cursor is PointingHand (Source target = clickable) + QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor); + + // ── Simulate a full refresh cycle (same order as controller.refresh) ── + ViewState vs = m_editor->saveViewState(); + m_editor->applyDocument(m_result); + m_editor->restoreViewState(vs); + + // Cursor must NOT have flipped to Arrow during applyDocument + // (applyHoverCursor is not called prematurely on composed text) + QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor); + + // updateCommandRow() — replaces line 0 text + m_editor->setCommandRowText(cmdText); + + // applySelectionOverlays() — must run AFTER updateCommandRow + m_editor->applySelectionOverlay(QSet()); + QApplication::processEvents(); + + // Re-query the position (text was replaced, byte offset may have shifted) + long posAfter = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN, + (unsigned long)0, (long)hoverCol); + int valAfter = (int)sci->SendScintilla( + QsciScintillaBase::SCI_INDICATORVALUEAT, + (unsigned long)IND_HOVER_SPAN, posAfter); + QVERIFY2(valAfter != 0, + "IND_HOVER_SPAN must survive refresh on command row " + "(hover should not flicker)"); + + // Cursor must still be PointingHand after full refresh cycle + QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor); + + m_editor->applyDocument(m_result); + } + + // ── Test: command row hover survives multiple rapid refresh cycles ── + void testCommandRowHoverSurvivesRepeatedRefresh() { + constexpr int IND_HOVER_SPAN = 11; + + m_editor->applyDocument(m_result); + + QString cmdText = QStringLiteral( + "source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct\u25BE _PEB64 {"); + m_editor->setCommandRowText(cmdText); + QApplication::processEvents(); + + auto* sci = m_editor->scintilla(); + int lineLen = (int)sci->SendScintilla( + QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0); + QByteArray buf(lineLen + 1, '\0'); + sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0, + (void*)buf.data()); + QString lineText = QString::fromUtf8(buf.constData(), lineLen); + while (lineText.endsWith('\n') || lineText.endsWith('\r')) + lineText.chop(1); + + ColumnSpan srcSpan = commandRowSrcSpan(lineText); + QVERIFY(srcSpan.valid); + int hoverCol = srcSpan.start + 1; + + // Move mouse into position + QPoint hoverPos = colToViewport(sci, 0, hoverCol); + sendMouseMove(sci->viewport(), hoverPos); + QApplication::processEvents(); + + // Simulate 5 rapid refresh cycles (like ~660ms timer x5) + for (int cycle = 0; cycle < 5; cycle++) { + ViewState vs = m_editor->saveViewState(); + m_editor->applyDocument(m_result); + m_editor->restoreViewState(vs); + m_editor->setCommandRowText(cmdText); + m_editor->applySelectionOverlay(QSet()); + + // Re-send mouse move each cycle (mouse is still there physically) + sendMouseMove(sci->viewport(), hoverPos); + QApplication::processEvents(); + + long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN, + (unsigned long)0, (long)hoverCol); + int val = (int)sci->SendScintilla( + QsciScintillaBase::SCI_INDICATORVALUEAT, + (unsigned long)IND_HOVER_SPAN, pos); + QVERIFY2(val != 0, + qPrintable(QString( + "IND_HOVER_SPAN lost on refresh cycle %1").arg(cycle))); + QVERIFY2(viewportCursor(m_editor) == Qt::PointingHandCursor, + qPrintable(QString( + "Cursor flipped away from PointingHand on cycle %1").arg(cycle))); + } + + m_editor->applyDocument(m_result); + } + // ── Test: MenuBarStyle gives QMenu items generous click targets ── // ── Test: M_ACCENT marker appears on selected rows ── void testAccentMarkerOnSelectedRows() { @@ -1117,6 +1255,157 @@ private slots: .arg(styled.height()).arg(base.height()))); } + // ── Test: non-hex nodes don't show false heat coloring after offset shift ── + void testDeleteClearsHeatOnShiftedNodes() { + // Heat indicator constants (replicated from editor.cpp) + constexpr int IND_HEAT_COLD = 13; + constexpr int IND_HEAT_WARM = 17; + constexpr int IND_HEAT_HOT = 18; + + // Build a small tree: root struct with mixed regular (non-hex) + hex fields + NodeTree tree; + tree.baseAddress = 0x1000; + + Node root; + root.kind = NodeKind::Struct; + root.structTypeName = "SmallStruct"; + root.name = "s"; + root.parentId = 0; + root.offset = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // field0: UInt32 at offset 0 (4 bytes) — will be deleted + // field1: UInt32 at offset 4 (4 bytes) — regular type, will shift + // field2: Float at offset 8 (4 bytes) — regular type, will shift + // field3: Hex32 at offset 12 (4 bytes) — hex type, will shift + struct FieldDef { int off; NodeKind kind; const char* name; }; + FieldDef defs[] = { + { 0, NodeKind::UInt32, "count"}, + { 4, NodeKind::UInt32, "flags"}, + { 8, NodeKind::Float, "speed"}, + {12, NodeKind::Hex32, "raw"}, + }; + QVector fieldIds; + for (auto& d : defs) { + Node n; + n.kind = d.kind; + n.name = d.name; + n.parentId = rootId; + n.offset = d.off; + int idx = tree.addNode(n); + fieldIds.append(tree.nodes[idx].id); + } + + // Create a provider with 16 bytes of recognizable data + QByteArray data(16, '\0'); + uint32_t v0 = 42; memcpy(data.data() + 0, &v0, 4); // count=42 + uint32_t v1 = 0xFF; memcpy(data.data() + 4, &v1, 4); // flags=255 + float v2 = 3.14f; memcpy(data.data() + 8, &v2, 4); // speed=3.14 + uint32_t v3 = 0xCAFE; memcpy(data.data() + 12, &v3, 4); // raw=0xCAFE + BufferProvider prov(data); + + // Compose the initial document + ComposeResult result = compose(tree, prov); + + // Inject heatLevel=2 (warm) on field1, field2, field3 — simulates + // heat accumulated before the delete + for (auto& lm : result.meta) { + for (int i = 1; i <= 3; i++) { + if (lm.nodeId == fieldIds[i]) + lm.heatLevel = 2; + } + } + + // Apply to editor — heat indicators should appear + m_editor->applyDocument(result); + QApplication::processEvents(); + + auto* sci = m_editor->scintilla(); + + // Helper: check if any heat indicator is set anywhere on a line + auto hasHeatOnLine = [&](int line) -> bool { + int lineLen = (int)sci->SendScintilla( + QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line); + long lineStart = sci->SendScintilla( + QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)line); + for (long pos = lineStart; pos < lineStart + lineLen; pos++) { + for (int ind : { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT }) { + int val = (int)sci->SendScintilla( + QsciScintillaBase::SCI_INDICATORVALUEAT, + (unsigned long)ind, pos); + if (val != 0) return true; + } + } + return false; + }; + + // Find lines for each shifted field + auto findFieldLine = [&](const ComposeResult& cr, uint64_t nodeId) -> int { + for (int i = 0; i < cr.meta.size(); i++) { + if (cr.meta[i].nodeId == nodeId && cr.meta[i].lineKind == LineKind::Field) + return i; + } + return -1; + }; + + int line1 = findFieldLine(result, fieldIds[1]); + int line2 = findFieldLine(result, fieldIds[2]); + int line3 = findFieldLine(result, fieldIds[3]); + QVERIFY(line1 >= 0); + QVERIFY(line2 >= 0); + QVERIFY(line3 >= 0); + + // Verify heat indicators ARE present (UInt32, Float, and Hex32) + QVERIFY2(hasHeatOnLine(line1), + "Heat should be present on UInt32 'flags' before delete"); + QVERIFY2(hasHeatOnLine(line2), + "Heat should be present on Float 'speed' before delete"); + QVERIFY2(hasHeatOnLine(line3), + "Heat should be present on Hex32 'raw' before delete"); + + // ── Simulate delete of field0 (UInt32 'count' at offset 0) ── + int field0Idx = tree.indexOfId(fieldIds[0]); + QVERIFY(field0Idx >= 0); + tree.nodes.remove(field0Idx); + tree.invalidateIdCache(); + + // Shift remaining fields' offsets down by 4 + for (int i = 1; i <= 3; i++) { + int fi = tree.indexOfId(fieldIds[i]); + if (fi >= 0) tree.nodes[fi].offset -= 4; + } + + // Recompose — heatLevel defaults to 0 (simulates cleared history) + ComposeResult afterResult = compose(tree, prov); + + // Apply the post-delete document to the editor + m_editor->applyDocument(afterResult); + QApplication::processEvents(); + + // Find new line positions + int newLine1 = findFieldLine(afterResult, fieldIds[1]); + int newLine2 = findFieldLine(afterResult, fieldIds[2]); + int newLine3 = findFieldLine(afterResult, fieldIds[3]); + QVERIFY(newLine1 >= 0); + QVERIFY(newLine2 >= 0); + QVERIFY(newLine3 >= 0); + + // After applying heatLevel=0, NO heat indicators should appear + QVERIFY2(!hasHeatOnLine(newLine1), + "UInt32 'flags' should NOT show heat after offset shift " + "(old values are from wrong address)"); + QVERIFY2(!hasHeatOnLine(newLine2), + "Float 'speed' should NOT show heat after offset shift " + "(old values are from wrong address)"); + QVERIFY2(!hasHeatOnLine(newLine3), + "Hex32 'raw' should NOT show heat after offset shift " + "(old values are from wrong address)"); + + // Restore original document + m_editor->applyDocument(m_result); + } + void testMenuHoverRendersAmberText() { // Replicate MenuBarStyle with drawControl hover override class TestMenuStyle : public QProxyStyle { diff --git a/tests/test_options_dialog.cpp b/tests/test_options_dialog.cpp index 9aaf100..370df5f 100644 --- a/tests/test_options_dialog.cpp +++ b/tests/test_options_dialog.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "optionsdialog.h" #include "themes/thememanager.h" @@ -222,6 +223,45 @@ private slots: QVERIFY(!aiItem->isHidden()); } + void refreshRateSpinBoxExists() { + OptionsResult defaults; + defaults.refreshMs = 660; + OptionsDialog dlg(defaults); + + auto* spin = dlg.findChild("refreshSpin"); + QVERIFY(spin); + QCOMPARE(spin->value(), 660); + QCOMPARE(spin->minimum(), 1); + QCOMPARE(spin->maximum(), 60000); + } + + void refreshRateResultReflectsInput() { + OptionsResult input; + input.refreshMs = 200; + OptionsDialog dlg(input); + + auto r = dlg.result(); + QCOMPARE(r.refreshMs, 200); + + // Change via spin box + auto* spin = dlg.findChild("refreshSpin"); + QVERIFY(spin); + spin->setValue(100); + r = dlg.result(); + QCOMPARE(r.refreshMs, 100); + } + + void refreshRateClampsMin() { + OptionsResult input; + input.refreshMs = 0; // below minimum + OptionsDialog dlg(input); + + auto* spin = dlg.findChild("refreshSpin"); + QVERIFY(spin); + // QSpinBox clamps to minimum + QCOMPARE(spin->value(), 1); + } + void dialogInheritsPalette() { auto& tm = ThemeManager::instance(); const auto& theme = tm.current();