diff --git a/src/controller.cpp b/src/controller.cpp index 4c36567..4e5471a 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -244,6 +244,17 @@ void RcxController::connectEditor(RcxEditor* editor) { showTypePopup(editor, mode, nodeIdx, globalPos); }); + // Insert key shortcut + connect(editor, &RcxEditor::insertAboveRequested, + this, [this](int nodeIdx, NodeKind kind) { + if (nodeIdx >= 0) + insertNodeAbove(nodeIdx, kind, QStringLiteral("field")); + else { + uint64_t target = m_viewRootId ? m_viewRootId : 0; + insertNode(target, -1, kind, QStringLiteral("field")); + } + }); + // Inline editing signals connect(editor, &RcxEditor::inlineEditCommitted, this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text, @@ -591,7 +602,8 @@ void RcxController::refresh() { else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive()) prov = m_doc->provider.get(); - if (m_trackValues && prov) { + if (m_valueTrackCooldown > 0) --m_valueTrackCooldown; + if (m_trackValues && prov && m_valueTrackCooldown <= 0) { for (auto& lm : m_lastResult.meta) { if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue; if (isSyntheticLine(lm) || lm.isContinuation) continue; @@ -708,6 +720,15 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) { m_doc->undoStack.push(new RcxCommand(this, cmd::ChangeKind{node.id, node.kind, newKind, {}})); + // Hex nodes don't display names (ASCII preview instead), so the stored + // name may be empty or stale. Give it a sensible default. + if (isHexNode(node.kind) && !isHexNode(newKind)) { + QString autoName = QStringLiteral("field_%1") + .arg(node.offset, 4, 16, QChar('0')); + m_doc->undoStack.push(new RcxCommand(this, + cmd::Rename{node.id, node.name, autoName})); + } + // Insert hex nodes to fill the gap (largest first for alignment) int padOffset = baseOffset; while (gap > 0) { @@ -741,8 +762,19 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) { adjs.append({sib.id, sib.offset, sib.offset + delta}); } } + bool needsRename = isHexNode(node.kind) && !isHexNode(newKind); + if (needsRename) { + m_doc->undoStack.beginMacro(QStringLiteral("Change type")); + } m_doc->undoStack.push(new RcxCommand(this, cmd::ChangeKind{node.id, node.kind, newKind, adjs})); + if (needsRename) { + QString autoName = QStringLiteral("field_%1") + .arg(node.offset, 4, 16, QChar('0')); + m_doc->undoStack.push(new RcxCommand(this, + cmd::Rename{node.id, node.name, autoName})); + m_doc->undoStack.endMacro(); + } } } @@ -782,6 +814,31 @@ void RcxController::insertNode(uint64_t parentId, int offset, NodeKind kind, con m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n})); } +void RcxController::insertNodeAbove(int beforeIdx, NodeKind kind, const QString& name) { + if (beforeIdx < 0 || beforeIdx >= m_doc->tree.nodes.size()) return; + const Node& before = m_doc->tree.nodes[beforeIdx]; + + Node n; + n.kind = kind; + n.name = name; + n.parentId = before.parentId; + n.offset = before.offset; + n.id = m_doc->tree.reserveId(); + + int insertSize = sizeForKind(kind); + + // Shift siblings at or after the insertion offset down + QVector adjs; + auto siblings = m_doc->tree.childrenOf(before.parentId); + for (int si : siblings) { + auto& sib = m_doc->tree.nodes[si]; + if (sib.offset >= before.offset) + adjs.append({sib.id, sib.offset, sib.offset + insertSize}); + } + + m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n, adjs})); +} + void RcxController::removeNode(int nodeIdx) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; const Node& node = m_doc->tree.nodes[nodeIdx]; @@ -1558,6 +1615,17 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, return indices; }; + // ── Insert shortcuts (always available) ── + menu.addAction(icon("diff-added.svg"), "Insert 4", [this]() { + uint64_t target = m_viewRootId ? m_viewRootId : 0; + insertNode(target, -1, NodeKind::Hex32, QStringLiteral("field")); + }); + menu.addAction(icon("diff-added.svg"), "Insert 8", [this]() { + uint64_t target = m_viewRootId ? m_viewRootId : 0; + insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field")); + }); + menu.addSeparator(); + // Quick-convert shortcuts when all selected nodes share the same kind NodeKind commonKind = NodeKind::Hex64; bool allSame = true; @@ -1640,8 +1708,10 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, for (int ci : m_doc->tree.subtreeIndices(id)) m_valueHistory.remove(m_doc->tree.nodes[ci].id); } - for (auto& lm : m_lastResult.meta) - if (!m_valueHistory.contains(lm.nodeId)) lm.heatLevel = 0; + m_refreshGen++; // discard in-flight async reads + m_prevPages.clear(); // clean baseline for next read cycle + m_changedOffsets.clear(); // no phantom change indicators + m_valueTrackCooldown = 5; // suppress tracking for ~1s refresh(); }); } @@ -1674,7 +1744,8 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, menu.addSeparator(); - menu.addAction(icon("link.svg"), "Copy &Address", [this, ids]() { + QMenu* copyMenu = menu.addMenu(icon("clippy.svg"), "Copy"); + copyMenu->addAction(icon("link.svg"), "Copy &Address", [this, ids]() { QStringList addrs; for (uint64_t id : ids) { int ni = m_doc->tree.indexOfId(id); @@ -1691,6 +1762,28 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, QMenu menu; + // ── Insert shortcuts (at very top) ── + if (hasNode) { + menu.addAction(icon("diff-added.svg"), "Insert 4 Above\tShift+Ins", + [this, nodeIdx]() { + insertNodeAbove(nodeIdx, NodeKind::Hex32, QStringLiteral("field")); + }); + menu.addAction(icon("diff-added.svg"), "Insert 8 Above\tIns", + [this, nodeIdx]() { + insertNodeAbove(nodeIdx, NodeKind::Hex64, QStringLiteral("field")); + }); + } else { + menu.addAction(icon("diff-added.svg"), "Insert 4", [this]() { + uint64_t target = m_viewRootId ? m_viewRootId : 0; + insertNode(target, -1, NodeKind::Hex32, QStringLiteral("field")); + }); + menu.addAction(icon("diff-added.svg"), "Insert 8", [this]() { + uint64_t target = m_viewRootId ? m_viewRootId : 0; + insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field")); + }); + } + menu.addSeparator(); + // ── Node-specific actions (only when clicking on a node) ── if (hasNode) { const Node& node = m_doc->tree.nodes[nodeIdx]; @@ -1839,8 +1932,10 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, m_valueHistory.remove(nodeId); for (int ci : m_doc->tree.subtreeIndices(nodeId)) m_valueHistory.remove(m_doc->tree.nodes[ci].id); - for (auto& lm : m_lastResult.meta) - if (!m_valueHistory.contains(lm.nodeId)) lm.heatLevel = 0; + m_refreshGen++; // discard in-flight async reads + m_prevPages.clear(); // clean baseline for next read cycle + m_changedOffsets.clear(); // no phantom change indicators + m_valueTrackCooldown = 5; // suppress tracking for ~1s refresh(); }); } @@ -1993,24 +2088,6 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, if (ni >= 0) removeNode(ni); }); - menu.addSeparator(); - - menu.addAction(icon("link.svg"), "Copy &Address", [this, nodeId]() { - int ni = m_doc->tree.indexOfId(nodeId); - if (ni < 0) return; - uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni); - QApplication::clipboard()->setText( - QStringLiteral("0x") + QString::number(addr, 16).toUpper()); - }); - - menu.addAction(icon("whole-word.svg"), "Copy &Offset", [this, nodeId]() { - int ni = m_doc->tree.indexOfId(nodeId); - if (ni < 0) return; - int off = m_doc->tree.nodes[ni].offset; - QApplication::clipboard()->setText( - QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0')); - }); - menu.addSeparator(); } // else (non-member node actions) } @@ -2091,10 +2168,26 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, menu.addSeparator(); - menu.addAction(icon("clippy.svg"), "Copy All as Text", [editor]() { - QApplication::clipboard()->setText(editor->textWithMargins()); - }); - menu.addAction(icon("clippy.svg"), "Copy Line", [editor, line]() { + QMenu* copyMenu = menu.addMenu(icon("clippy.svg"), "Copy"); + if (hasNode) { + uint64_t copyNodeId = m_doc->tree.nodes[nodeIdx].id; + copyMenu->addAction(icon("link.svg"), "Copy &Address", [this, copyNodeId]() { + int ni = m_doc->tree.indexOfId(copyNodeId); + if (ni < 0) return; + uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni); + QApplication::clipboard()->setText( + QStringLiteral("0x") + QString::number(addr, 16).toUpper()); + }); + copyMenu->addAction(icon("whole-word.svg"), "Copy &Offset", [this, copyNodeId]() { + int ni = m_doc->tree.indexOfId(copyNodeId); + if (ni < 0) return; + int off = m_doc->tree.nodes[ni].offset; + QApplication::clipboard()->setText( + QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0')); + }); + copyMenu->addSeparator(); + } + copyMenu->addAction("Copy Line", [editor, line]() { auto* sci = editor->scintilla(); int len = (int)sci->SendScintilla(QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line); if (len > 0) { @@ -2105,11 +2198,14 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, QApplication::clipboard()->setText(text); } }); + copyMenu->addAction("Copy All as Text", [editor]() { + QApplication::clipboard()->setText(editor->textWithMargins()); + }); menu.addSeparator(); - menu.addAction(icon("search.svg"), "Search...", [editor]() { - editor->showFindBar(); + menu.addAction(icon("search.svg"), "Search...\tCtrl+F", [editor]() { + QTimer::singleShot(0, editor, &RcxEditor::showFindBar); }); menu.exec(globalPos); diff --git a/src/controller.h b/src/controller.h index d9a5c57..797690a 100644 --- a/src/controller.h +++ b/src/controller.h @@ -90,6 +90,7 @@ public: void changeNodeKind(int nodeIdx, NodeKind newKind); void renameNode(int nodeIdx, const QString& newName); void insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name); + void insertNodeAbove(int beforeIdx, NodeKind kind, const QString& name); void removeNode(int nodeIdx); void toggleCollapse(int nodeIdx); void materializeRefChildren(int nodeIdx); @@ -147,8 +148,9 @@ public: // Cross-tab type visibility: point at the project's full document list void setProjectDocuments(QVector* docs) { m_projectDocs = docs; } - // Test accessor + // Test accessors const QHash& valueHistory() const { return m_valueHistory; } + const ComposeResult& lastResult() const { return m_lastResult; } signals: void nodeSelected(int nodeIdx); @@ -181,6 +183,7 @@ private: QSet m_changedOffsets; QHash m_valueHistory; bool m_trackValues = true; + int m_valueTrackCooldown = 0; uint64_t m_refreshGen = 0; uint64_t m_readGen = 0; bool m_readInFlight = false; diff --git a/src/editor.cpp b/src/editor.cpp index 1f87499..ed8c1e2 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1471,7 +1471,12 @@ void RcxEditor::applyHeatmapHighlight(const QVector& meta) { int typeW = lm.effectiveTypeW; int nameW = lm.effectiveNameW; - if (heat <= 0) continue; + if (heat <= 0) { + // Clear any stale heat indicators from a previous frame + for (int hi : heatIndicators) + clearIndicatorLine(hi, i); + continue; + } // Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot) int activeInd = heatIndicators[qBound(0, heat - 1, 2)]; @@ -2242,6 +2247,12 @@ bool RcxEditor::handleNormalKey(QKeyEvent* ke) { case Qt::Key_Return: case Qt::Key_Enter: return beginInlineEdit(EditTarget::Value); + case Qt::Key_Insert: + if (ke->modifiers() & Qt::ShiftModifier) + emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex32); + else + emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex64); + return true; case Qt::Key_Tab: { EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value, EditTarget::ArrayElementType, EditTarget::ArrayElementCount, diff --git a/src/editor.h b/src/editor.h index 5392e5e..407aeb6 100644 --- a/src/editor.h +++ b/src/editor.h @@ -80,6 +80,7 @@ signals: void inlineEditCancelled(); void typeSelectorRequested(); void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos); + void insertAboveRequested(int nodeIdx, NodeKind kind); protected: bool eventFilter(QObject* obj, QEvent* event) override; diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 3eb0d2b..ee9850d 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -815,6 +815,68 @@ private slots: QCOMPARE(m_doc->tree.nodes[idx].isStatic, true); } + // ── Test: clearing value history actually resets heat to 0 ── + void testClearValueHistoryResetsHeat() { + // Use a live provider so value tracking runs during refresh() + m_doc->provider = std::make_unique(makeSmallBuffer(), 0); + m_ctrl->setTrackValues(true); + + // Do initial refresh to populate m_lastResult.meta + m_ctrl->refresh(); + QApplication::processEvents(); + + // Find field_u32 nodeId + uint64_t targetId = 0; + for (const auto& n : m_doc->tree.nodes) { + if (n.name == "field_u32") { targetId = n.id; break; } + } + QVERIFY(targetId != 0); + + // Seed value history with multiple changes to get heat > 0 + auto& history = const_cast&>(m_ctrl->valueHistory()); + history[targetId].record("val_1"); + history[targetId].record("val_2"); + history[targetId].record("val_3"); + QVERIFY2(history[targetId].heatLevel() >= 2, + "Pre-clear: should have heat >= 2 (warm)"); + + // Refresh so heatLevel propagates to LineMeta + m_ctrl->refresh(); + QApplication::processEvents(); + + // Verify heat is visible in meta + bool foundHot = false; + for (const auto& lm : m_ctrl->lastResult().meta) { + if (lm.nodeId == targetId && lm.heatLevel > 0) { + foundHot = true; + break; + } + } + QVERIFY2(foundHot, "Pre-clear: LineMeta should show heat > 0"); + + // Now simulate what the "Clear Value History" context menu does: + // remove from history map + clear subtree + refresh + history.remove(targetId); + for (int ci : m_doc->tree.subtreeIndices(targetId)) + history.remove(m_doc->tree.nodes[ci].id); + + m_ctrl->refresh(); + QApplication::processEvents(); + + // After clear + refresh, heatLevel must be 0 for this node + for (const auto& lm : m_ctrl->lastResult().meta) { + if (lm.nodeId == targetId) { + QCOMPARE(lm.heatLevel, 0); + } + } + + // The history entry should exist again (re-recorded by refresh) + // but with only 1 unique value → heatLevel 0 + QVERIFY(history.contains(targetId)); + QCOMPARE(history[targetId].heatLevel(), 0); + QCOMPARE(history[targetId].uniqueCount(), 1); + } + void testStaticFieldTypeChangePreservesFlags() { uint64_t rootId = m_doc->tree.nodes[0].id;