feat: fix heatmap false-heat on offset shift, hover flicker, type chooser cleanup

- 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
This commit is contained in:
IChooseYou
2026-02-17 11:41:46 -07:00
parent 5ae9ca0979
commit 1c3b4af045
18 changed files with 996 additions and 498 deletions

View File

@@ -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<uint64_t>(nodeOff);
uint64_t addr = static_cast<uint64_t>(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<cmd::OffsetAdj>& 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<decltype(c)>;
if constexpr (std::is_same_v<T, cmd::ChangeKind>) {
@@ -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<T, cmd::Rename>) {
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<T, cmd::Remove>) {
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<T, cmd::ChangeBase>) {
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();